From 20c7191eb67c5cccda18c547de9b4eb4994e3654 Mon Sep 17 00:00:00 2001 From: zj <1052308357@qq.com> Date: Sun, 23 Nov 2025 22:11:53 +0800 Subject: [PATCH] fix --- .gitignore | 8 +- 1-get-all-alci-gits-v1.sh | 36 + calamares/src/CMakeLists.txt | 26 + calamares/src/branding/CMakeLists.txt | 10 + calamares/src/branding/README.md | 225 + calamares/src/branding/default/banner.png | Bin 0 -> 5937 bytes .../src/branding/default/banner.png.license | 2 + calamares/src/branding/default/branding.desc | 239 + .../default/lang/calamares-default_ar.ts | 17 + .../default/lang/calamares-default_en.ts | 17 + .../default/lang/calamares-default_eo.ts | 17 + .../default/lang/calamares-default_fr.ts | 17 + .../default/lang/calamares-default_nl.ts | 17 + calamares/src/branding/default/languages.png | Bin 0 -> 86002 bytes .../branding/default/languages.png.license | 2 + calamares/src/branding/default/show.qml | 77 + calamares/src/branding/default/squid.png | Bin 0 -> 8313 bytes .../src/branding/default/squid.png.license | 2 + calamares/src/branding/default/stylesheet.qss | 96 + calamares/src/calamares/CMakeLists.txt | 67 + .../src/calamares/CalamaresApplication.cpp | 302 + .../src/calamares/CalamaresApplication.h | 64 + calamares/src/calamares/CalamaresWindow.cpp | 552 ++ calamares/src/calamares/CalamaresWindow.h | 50 + calamares/src/calamares/DebugWindow.cpp | 257 + calamares/src/calamares/DebugWindow.h | 96 + calamares/src/calamares/DebugWindow.ui | 157 + calamares/src/calamares/VariantModel.cpp | 285 + calamares/src/calamares/VariantModel.h | 104 + .../src/calamares/calamares-navigation.qml | 83 + calamares/src/calamares/calamares-sidebar.qml | 125 + calamares/src/calamares/calamares.qrc | 10 + calamares/src/calamares/main.cpp | 154 + .../progresstree/ProgressTreeDelegate.cpp | 119 + .../progresstree/ProgressTreeDelegate.h | 31 + .../progresstree/ProgressTreeView.cpp | 61 + .../calamares/progresstree/ProgressTreeView.h | 39 + calamares/src/calamares/test_conf.cpp | 109 + calamares/src/calamares/testmain.cpp | 573 ++ calamares/src/libcalamares/CMakeLists.txt | 255 + calamares/src/libcalamares/CalamaresAbout.cpp | 76 + calamares/src/libcalamares/CalamaresAbout.h | 31 + .../src/libcalamares/CalamaresConfig.h.in | 34 + .../src/libcalamares/CalamaresVersion.h.in | 17 + .../src/libcalamares/CalamaresVersionX.h.in | 16 + calamares/src/libcalamares/CppJob.cpp | 38 + calamares/src/libcalamares/CppJob.h | 44 + calamares/src/libcalamares/DllMacro.h | 68 + calamares/src/libcalamares/GlobalStorage.cpp | 244 + calamares/src/libcalamares/GlobalStorage.h | 192 + calamares/src/libcalamares/Job.cpp | 112 + calamares/src/libcalamares/Job.h | 175 + calamares/src/libcalamares/JobExample.cpp | 33 + calamares/src/libcalamares/JobExample.h | 69 + calamares/src/libcalamares/JobQueue.cpp | 618 ++ calamares/src/libcalamares/JobQueue.h | 122 + calamares/src/libcalamares/ProcessJob.cpp | 65 + calamares/src/libcalamares/ProcessJob.h | 46 + calamares/src/libcalamares/Settings.cpp | 483 ++ calamares/src/libcalamares/Settings.h | 205 + calamares/src/libcalamares/Tests.cpp | 669 ++ calamares/src/libcalamares/compat/CheckBox.h | 30 + calamares/src/libcalamares/compat/Mutex.h | 30 + calamares/src/libcalamares/compat/Size.h | 23 + calamares/src/libcalamares/compat/Variant.h | 57 + calamares/src/libcalamares/compat/Xml.h | 42 + .../src/libcalamares/geoip/GeoIPFixed.cpp | 35 + calamares/src/libcalamares/geoip/GeoIPFixed.h | 43 + .../src/libcalamares/geoip/GeoIPJSON.cpp | 92 + calamares/src/libcalamares/geoip/GeoIPJSON.h | 44 + .../src/libcalamares/geoip/GeoIPTests.cpp | 262 + calamares/src/libcalamares/geoip/GeoIPTests.h | 37 + calamares/src/libcalamares/geoip/GeoIPXML.cpp | 92 + calamares/src/libcalamares/geoip/GeoIPXML.h | 44 + calamares/src/libcalamares/geoip/Handler.cpp | 176 + calamares/src/libcalamares/geoip/Handler.h | 86 + .../src/libcalamares/geoip/Interface.cpp | 46 + calamares/src/libcalamares/geoip/Interface.h | 127 + .../src/libcalamares/geoip/test_geoip.cpp | 85 + .../src/libcalamares/locale/CountryData_p.cpp | 255 + calamares/src/libcalamares/locale/Global.cpp | 91 + calamares/src/libcalamares/locale/Global.h | 83 + calamares/src/libcalamares/locale/Lookup.cpp | 98 + calamares/src/libcalamares/locale/Lookup.h | 47 + calamares/src/libcalamares/locale/Tests.cpp | 553 ++ .../src/libcalamares/locale/TimeZone.cpp | 501 ++ calamares/src/libcalamares/locale/TimeZone.h | 237 + .../locale/TranslatableConfiguration.cpp | 118 + .../locale/TranslatableConfiguration.h | 104 + .../locale/TranslatableString.cpp | 77 + .../libcalamares/locale/TranslatableString.h | 58 + .../src/libcalamares/locale/Translation.cpp | 246 + .../src/libcalamares/locale/Translation.h | 145 + .../libcalamares/locale/TranslationsModel.cpp | 155 + .../libcalamares/locale/TranslationsModel.h | 94 + .../src/libcalamares/locale/ZoneData_p.cxxtr | 480 ++ .../src/libcalamares/locale/cldr-extractor.py | 271 + .../src/libcalamares/locale/zone-extractor.py | 83 + .../src/libcalamares/modulesystem/Actions.h | 29 + .../src/libcalamares/modulesystem/Config.cpp | 135 + .../src/libcalamares/modulesystem/Config.h | 147 + .../libcalamares/modulesystem/Descriptor.cpp | 171 + .../libcalamares/modulesystem/Descriptor.h | 148 + .../libcalamares/modulesystem/InstanceKey.cpp | 38 + .../libcalamares/modulesystem/InstanceKey.h | 117 + .../src/libcalamares/modulesystem/Module.cpp | 154 + .../src/libcalamares/modulesystem/Module.h | 169 + .../src/libcalamares/modulesystem/Preset.cpp | 82 + .../src/libcalamares/modulesystem/Preset.h | 91 + .../libcalamares/modulesystem/Requirement.h | 61 + .../modulesystem/RequirementsChecker.cpp | 134 + .../modulesystem/RequirementsChecker.h | 73 + .../modulesystem/RequirementsModel.cpp | 129 + .../modulesystem/RequirementsModel.h | 100 + .../src/libcalamares/modulesystem/Tests.cpp | 179 + .../src/libcalamares/network/Manager.cpp | 438 ++ calamares/src/libcalamares/network/Manager.h | 166 + calamares/src/libcalamares/network/Tests.cpp | 147 + calamares/src/libcalamares/network/Tests.h | 32 + .../src/libcalamares/packages/Globals.cpp | 88 + calamares/src/libcalamares/packages/Globals.h | 45 + calamares/src/libcalamares/packages/Tests.cpp | 236 + .../src/libcalamares/partition/AutoMount.cpp | 176 + .../src/libcalamares/partition/AutoMount.h | 51 + .../src/libcalamares/partition/FileSystem.cpp | 87 + .../src/libcalamares/partition/FileSystem.h | 100 + .../src/libcalamares/partition/Global.cpp | 55 + calamares/src/libcalamares/partition/Global.h | 78 + .../src/libcalamares/partition/KPMHelper.h | 43 + .../src/libcalamares/partition/KPMManager.cpp | 100 + .../src/libcalamares/partition/KPMManager.h | 63 + .../src/libcalamares/partition/KPMTests.cpp | 118 + .../src/libcalamares/partition/Mount.cpp | 159 + calamares/src/libcalamares/partition/Mount.h | 112 + .../partition/PartitionIterator.cpp | 139 + .../partition/PartitionIterator.h | 69 + .../libcalamares/partition/PartitionQuery.cpp | 115 + .../libcalamares/partition/PartitionQuery.h | 77 + .../libcalamares/partition/PartitionSize.cpp | 291 + .../libcalamares/partition/PartitionSize.h | 120 + calamares/src/libcalamares/partition/Sync.cpp | 35 + calamares/src/libcalamares/partition/Sync.h | 40 + .../src/libcalamares/partition/Tests.cpp | 227 + .../libcalamares/partition/calautomount.cpp | 52 + calamares/src/libcalamares/pybind11/Api.cpp | 322 + calamares/src/libcalamares/pybind11/Api.h | 89 + .../libcalamares/pybind11/Pybind11Helpers.h | 30 + .../src/libcalamares/pybind11/PythonJob.cpp | 434 ++ .../src/libcalamares/pybind11/PythonJob.h | 73 + .../src/libcalamares/pybind11/PythonTypes.h | 56 + .../src/libcalamares/pyboost/PythonHelper.cpp | 374 ++ .../src/libcalamares/pyboost/PythonHelper.h | 78 + .../src/libcalamares/pyboost/PythonJob.cpp | 394 ++ .../src/libcalamares/pyboost/PythonJob.h | 84 + .../src/libcalamares/pyboost/PythonJobApi.cpp | 207 + .../src/libcalamares/pyboost/PythonJobApi.h | 71 + .../src/libcalamares/pyboost/PythonTypes.h | 82 + calamares/src/libcalamares/python/Api.cpp | 207 + calamares/src/libcalamares/python/Api.h | 48 + calamares/src/libcalamares/python/Variant.cpp | 114 + calamares/src/libcalamares/python/Variant.h | 40 + .../libcalamares/testdata/localetest_nl.ts | 15 + .../src/libcalamares/testdata/yaml-list.conf | 17 + .../src/libcalamares/utils/CommandList.cpp | 318 + .../src/libcalamares/utils/CommandList.h | 167 + calamares/src/libcalamares/utils/Dirs.cpp | 183 + calamares/src/libcalamares/utils/Dirs.h | 61 + calamares/src/libcalamares/utils/Entropy.cpp | 122 + calamares/src/libcalamares/utils/Entropy.h | 46 + calamares/src/libcalamares/utils/Logger.cpp | 311 + calamares/src/libcalamares/utils/Logger.h | 404 ++ calamares/src/libcalamares/utils/NamedEnum.h | 263 + .../src/libcalamares/utils/NamedSuffix.h | 100 + .../src/libcalamares/utils/Permissions.cpp | 212 + .../src/libcalamares/utils/Permissions.h | 113 + .../src/libcalamares/utils/PluginFactory.cpp | 13 + .../src/libcalamares/utils/PluginFactory.h | 113 + calamares/src/libcalamares/utils/RAII.h | 112 + .../src/libcalamares/utils/Retranslator.cpp | 243 + .../src/libcalamares/utils/Retranslator.h | 145 + calamares/src/libcalamares/utils/Runner.cpp | 248 + calamares/src/libcalamares/utils/Runner.h | 135 + calamares/src/libcalamares/utils/String.cpp | 250 + calamares/src/libcalamares/utils/String.h | 112 + .../src/libcalamares/utils/StringExpander.cpp | 87 + .../src/libcalamares/utils/StringExpander.h | 77 + calamares/src/libcalamares/utils/System.cpp | 345 + calamares/src/libcalamares/utils/System.h | 365 ++ .../src/libcalamares/utils/TestPaths.cpp | 260 + calamares/src/libcalamares/utils/Tests.cpp | 1468 +++++ calamares/src/libcalamares/utils/Traits.h | 77 + calamares/src/libcalamares/utils/UMask.cpp | 37 + calamares/src/libcalamares/utils/UMask.h | 50 + calamares/src/libcalamares/utils/Units.h | 181 + calamares/src/libcalamares/utils/Variant.cpp | 139 + calamares/src/libcalamares/utils/Variant.h | 78 + calamares/src/libcalamares/utils/Yaml.cpp | 330 + calamares/src/libcalamares/utils/Yaml.h | 85 + .../src/libcalamares/utils/moc-warnings.h | 35 + calamares/src/libcalamaresui/Branding.cpp | 660 ++ calamares/src/libcalamaresui/Branding.h | 326 + calamares/src/libcalamaresui/CMakeLists.txt | 90 + calamares/src/libcalamaresui/ViewManager.cpp | 650 ++ calamares/src/libcalamaresui/ViewManager.h | 296 + .../src/libcalamaresui/libcalamaresui.qrc | 28 + .../modulesystem/CppJobModule.cpp | 118 + .../modulesystem/CppJobModule.h | 50 + .../modulesystem/ModuleFactory.cpp | 137 + .../modulesystem/ModuleFactory.h | 38 + .../modulesystem/ModuleManager.cpp | 440 ++ .../modulesystem/ModuleManager.h | 177 + .../modulesystem/ProcessJobModule.cpp | 77 + .../modulesystem/ProcessJobModule.h | 52 + .../modulesystem/PythonJobModule.cpp | 84 + .../modulesystem/PythonJobModule.h | 47 + .../modulesystem/ViewModule.cpp | 129 + .../libcalamaresui/modulesystem/ViewModule.h | 53 + calamares/src/libcalamaresui/utils/Gui.cpp | 194 + calamares/src/libcalamaresui/utils/Gui.h | 102 + .../libcalamaresui/utils/ImageRegistry.cpp | 128 + .../src/libcalamaresui/utils/ImageRegistry.h | 32 + calamares/src/libcalamaresui/utils/Paste.cpp | 196 + calamares/src/libcalamaresui/utils/Paste.h | 46 + calamares/src/libcalamaresui/utils/Qml.cpp | 254 + calamares/src/libcalamaresui/utils/Qml.h | 90 + calamares/src/libcalamaresui/utils/QtCompat.h | 30 + .../src/libcalamaresui/utils/TestPaste.cpp | 86 + .../viewpages/BlankViewStep.cpp | 110 + .../libcalamaresui/viewpages/BlankViewStep.h | 54 + .../viewpages/ExecutionViewStep.cpp | 244 + .../viewpages/ExecutionViewStep.h | 80 + .../libcalamaresui/viewpages/QmlViewStep.cpp | 301 + .../libcalamaresui/viewpages/QmlViewStep.h | 127 + .../libcalamaresui/viewpages/Slideshow.cpp | 283 + .../src/libcalamaresui/viewpages/Slideshow.h | 142 + .../src/libcalamaresui/viewpages/ViewStep.cpp | 91 + .../src/libcalamaresui/viewpages/ViewStep.h | 199 + .../libcalamaresui/widgets/ClickableLabel.cpp | 52 + .../libcalamaresui/widgets/ClickableLabel.h | 51 + .../libcalamaresui/widgets/ErrorDialog.cpp | 106 + .../src/libcalamaresui/widgets/ErrorDialog.h | 83 + .../src/libcalamaresui/widgets/ErrorDialog.ui | 137 + .../widgets/FixedAspectRatioLabel.cpp | 39 + .../widgets/FixedAspectRatioLabel.h | 34 + .../src/libcalamaresui/widgets/LogWidget.cpp | 112 + .../src/libcalamaresui/widgets/LogWidget.h | 55 + .../widgets/PrettyRadioButton.cpp | 130 + .../widgets/PrettyRadioButton.h | 79 + .../libcalamaresui/widgets/TranslationFix.cpp | 61 + .../libcalamaresui/widgets/TranslationFix.h | 34 + .../libcalamaresui/widgets/WaitingWidget.cpp | 152 + .../libcalamaresui/widgets/WaitingWidget.h | 78 + .../widgets/waitingspinnerwidget.cpp | 390 ++ .../widgets/waitingspinnerwidget.h | 162 + calamares/src/modules/CMakeLists.txt | 63 + calamares/src/modules/README.md | 546 ++ .../src/modules/bootloader/bootloader.conf | 86 + .../modules/bootloader/bootloader.schema.yaml | 27 + calamares/src/modules/bootloader/main.py | 974 +++ calamares/src/modules/bootloader/module.desc | 10 + calamares/src/modules/bootloader/test.yaml | 7 + .../modules/bootloader/tests/CMakeTests.txt | 10 + .../tests/test-bootloader-efiname.py | 67 + .../src/modules/contextualprocess/Binding.h | 84 + .../modules/contextualprocess/CMakeLists.txt | 20 + .../ContextualProcessJob.cpp | 179 + .../contextualprocess/ContextualProcessJob.h | 48 + .../src/modules/contextualprocess/Tests.cpp | 182 + .../src/modules/contextualprocess/Tests.h | 31 + .../contextualprocess/contextualprocess.conf | 63 + .../displaymanager/displaymanager.conf | 80 + .../displaymanager/displaymanager.schema.yaml | 39 + calamares/src/modules/displaymanager/main.py | 1046 +++ .../src/modules/displaymanager/module.desc | 7 + .../src/modules/displaymanager/tests/1.global | 3 + .../displaymanager/tests/CMakeTests.txt | 13 + .../displaymanager/tests/test-dm-gdm.py | 26 + .../displaymanager/tests/test-dm-greetd.py | 33 + .../displaymanager/tests/test-dm-sddm.py | 18 + calamares/src/modules/dracut/dracut.conf | 14 + .../src/modules/dracut/dracut.schema.yaml | 10 + calamares/src/modules/dracut/main.py | 64 + calamares/src/modules/dracut/module.desc | 7 + .../src/modules/dracutlukscfg/CMakeLists.txt | 13 + .../dracutlukscfg/DracutLuksCfgJob.cpp | 159 + .../modules/dracutlukscfg/DracutLuksCfgJob.h | 40 + calamares/src/modules/dummycpp/CMakeLists.txt | 12 + .../src/modules/dummycpp/DummyCppJob.cpp | 154 + calamares/src/modules/dummycpp/DummyCppJob.h | 43 + calamares/src/modules/dummycpp/dummycpp.conf | 24 + calamares/src/modules/dummycpp/module.desc | 22 + .../src/modules/dummyprocess/module.desc | 11 + .../src/modules/dummypython/dummypython.conf | 24 + calamares/src/modules/dummypython/main.py | 122 + calamares/src/modules/dummypython/module.desc | 9 + .../src/modules/dummypython/tests/1.global | 6 + calamares/src/modules/finished/CMakeLists.txt | 22 + calamares/src/modules/finished/Config.cpp | 230 + calamares/src/modules/finished/Config.h | 130 + .../src/modules/finished/FinishedPage.cpp | 128 + calamares/src/modules/finished/FinishedPage.h | 44 + .../src/modules/finished/FinishedPage.ui | 146 + .../src/modules/finished/FinishedViewStep.cpp | 105 + .../src/modules/finished/FinishedViewStep.h | 52 + calamares/src/modules/finished/finished.conf | 47 + .../src/modules/finished/finished.schema.yaml | 13 + .../src/modules/finishedq/CMakeLists.txt | 31 + .../modules/finishedq/FinishedQmlViewStep.cpp | 92 + .../modules/finishedq/FinishedQmlViewStep.h | 57 + .../src/modules/finishedq/finishedq-qt6.qml | 99 + .../src/modules/finishedq/finishedq-qt6.qrc | 6 + .../src/modules/finishedq/finishedq.conf | 36 + calamares/src/modules/finishedq/finishedq.qml | 101 + calamares/src/modules/finishedq/finishedq.qrc | 6 + .../modules/finishedq/finishedq@mobile.qml | 122 + calamares/src/modules/finishedq/seedling.svg | 1 + .../modules/finishedq/seedling.svg.license | 2 + .../src/modules/fsresizer/CMakeLists.txt | 38 + .../src/modules/fsresizer/ResizeFSJob.cpp | 259 + calamares/src/modules/fsresizer/ResizeFSJob.h | 71 + calamares/src/modules/fsresizer/Tests.cpp | 125 + calamares/src/modules/fsresizer/Tests.h | 30 + .../src/modules/fsresizer/fsresizer.conf | 52 + calamares/src/modules/fstab/fstab.conf | 37 + calamares/src/modules/fstab/fstab.schema.yaml | 28 + calamares/src/modules/fstab/main.py | 436 ++ calamares/src/modules/fstab/module.desc | 7 + calamares/src/modules/fstab/test.yaml | 16 + calamares/src/modules/fstab/test2.yaml | 25 + calamares/src/modules/grubcfg/grubcfg.conf | 51 + .../src/modules/grubcfg/grubcfg.schema.yaml | 23 + calamares/src/modules/grubcfg/main.py | 329 + calamares/src/modules/grubcfg/module.desc | 7 + calamares/src/modules/grubcfg/tests/1.global | 5 + calamares/src/modules/grubcfg/tests/2.global | 11 + calamares/src/modules/grubcfg/tests/2.job | 11 + calamares/src/modules/grubcfg/tests/3.global | 11 + calamares/src/modules/grubcfg/tests/3.job | 12 + calamares/src/modules/grubcfg/tests/4.global | 11 + calamares/src/modules/grubcfg/tests/4.job | 12 + .../src/modules/grubcfg/tests/CMakeTests.txt | 17 + calamares/src/modules/hostinfo/CMakeLists.txt | 37 + .../src/modules/hostinfo/HostInfoJob.cpp | 190 + calamares/src/modules/hostinfo/HostInfoJob.h | 55 + calamares/src/modules/hostinfo/Tests.cpp | 87 + calamares/src/modules/hwclock/main.py | 56 + calamares/src/modules/hwclock/module.desc | 8 + calamares/src/modules/initcpio/CMakeLists.txt | 20 + .../src/modules/initcpio/InitcpioJob.cpp | 97 + calamares/src/modules/initcpio/InitcpioJob.h | 41 + calamares/src/modules/initcpio/Tests.cpp | 46 + calamares/src/modules/initcpio/Tests.h | 27 + calamares/src/modules/initcpio/initcpio.conf | 26 + .../src/modules/initcpio/initcpio.schema.yaml | 11 + .../src/modules/initcpiocfg/initcpiocfg.conf | 38 + .../initcpiocfg/initcpiocfg.schema.yaml | 17 + calamares/src/modules/initcpiocfg/main.py | 221 + calamares/src/modules/initcpiocfg/module.desc | 12 + calamares/src/modules/initcpiocfg/test.yaml | 7 + .../src/modules/initramfs/CMakeLists.txt | 20 + .../src/modules/initramfs/InitramfsJob.cpp | 90 + .../src/modules/initramfs/InitramfsJob.h | 41 + calamares/src/modules/initramfs/Tests.cpp | 74 + calamares/src/modules/initramfs/Tests.h | 30 + .../src/modules/initramfs/initramfs.conf | 39 + .../src/modules/initramfscfg/encrypt_hook | 29 + .../modules/initramfscfg/encrypt_hook_nokey | 25 + calamares/src/modules/initramfscfg/main.py | 94 + .../src/modules/initramfscfg/module.desc | 8 + .../interactiveterminal/CMakeLists.txt | 25 + .../InteractiveTerminalPage.cpp | 128 + .../InteractiveTerminalPage.h | 37 + .../InteractiveTerminalViewStep.cpp | 95 + .../InteractiveTerminalViewStep.h | 54 + .../interactiveterminal.conf | 17 + .../modules/keyboard/AdditionalLayoutInfo.h | 33 + calamares/src/modules/keyboard/CMakeLists.txt | 27 + calamares/src/modules/keyboard/Config.cpp | 917 +++ calamares/src/modules/keyboard/Config.h | 147 + .../src/modules/keyboard/KeyboardData_p.cxxtr | 880 +++ .../modules/keyboard/KeyboardLayoutModel.cpp | 287 + .../modules/keyboard/KeyboardLayoutModel.h | 175 + .../src/modules/keyboard/KeyboardPage.cpp | 139 + calamares/src/modules/keyboard/KeyboardPage.h | 48 + .../src/modules/keyboard/KeyboardPage.ui | 189 + .../src/modules/keyboard/KeyboardViewStep.cpp | 118 + .../src/modules/keyboard/KeyboardViewStep.h | 57 + .../modules/keyboard/SetKeyboardLayoutJob.cpp | 423 ++ .../modules/keyboard/SetKeyboardLayoutJob.h | 51 + calamares/src/modules/keyboard/Tests.cpp | 68 + .../src/modules/keyboard/images/restore.png | Bin 0 -> 876 bytes .../keyboard/images/restore.png.license | 2 + calamares/src/modules/keyboard/kbd-model-map | 92 + calamares/src/modules/keyboard/keyboard.conf | 47 + calamares/src/modules/keyboard/keyboard.qrc | 7 + .../src/modules/keyboard/keyboard.schema.yaml | 19 + .../keyboardwidget/keyboardglobal.cpp | 264 + .../keyboard/keyboardwidget/keyboardglobal.h | 40 + .../keyboardwidget/keyboardpreview.cpp | 403 ++ .../keyboard/keyboardwidget/keyboardpreview.h | 77 + .../src/modules/keyboard/layout-extractor.py | 109 + .../src/modules/keyboard/non-ascii-layouts | 51 + .../src/modules/keyboardq/CMakeLists.txt | 29 + .../modules/keyboardq/KeyboardQmlViewStep.cpp | 100 + .../modules/keyboardq/KeyboardQmlViewStep.h | 53 + .../src/modules/keyboardq/data/Key-qt6.qml | 180 + calamares/src/modules/keyboardq/data/Key.qml | 180 + .../modules/keyboardq/data/Keyboard-qt6.qml | 224 + .../src/modules/keyboardq/data/Keyboard.qml | 223 + .../src/modules/keyboardq/data/afgani-qt6.xml | 65 + .../src/modules/keyboardq/data/afgani.xml | 65 + .../src/modules/keyboardq/data/ar-qt6.xml | 65 + calamares/src/modules/keyboardq/data/ar.xml | 65 + .../src/modules/keyboardq/data/backspace.svg | 7 + .../keyboardq/data/backspace.svg.license | 2 + .../keyboardq/data/button_bkg_center.png | Bin 0 -> 4289 bytes .../data/button_bkg_center.png.license | 2 + .../keyboardq/data/button_bkg_left.png | Bin 0 -> 5549 bytes .../data/button_bkg_left.png.license | 2 + .../keyboardq/data/button_bkg_right.png | Bin 0 -> 5955 bytes .../data/button_bkg_right.png.license | 2 + .../src/modules/keyboardq/data/de-qt6.xml | 66 + calamares/src/modules/keyboardq/data/de.xml | 66 + .../src/modules/keyboardq/data/empty-qt6.xml | 64 + .../src/modules/keyboardq/data/empty.xml | 64 + .../src/modules/keyboardq/data/en-qt6.xml | 64 + calamares/src/modules/keyboardq/data/en.xml | 64 + .../src/modules/keyboardq/data/enter.svg | 43 + .../modules/keyboardq/data/enter.svg.license | 2 + .../src/modules/keyboardq/data/es-qt6.xml | 64 + calamares/src/modules/keyboardq/data/es.xml | 64 + .../src/modules/keyboardq/data/fr-qt6.xml | 66 + calamares/src/modules/keyboardq/data/fr.xml | 66 + .../modules/keyboardq/data/generic-qt6.xml | 64 + .../src/modules/keyboardq/data/generic.xml | 64 + .../modules/keyboardq/data/generic_qz-qt6.xml | 66 + .../src/modules/keyboardq/data/generic_qz.xml | 66 + .../keyboardq/data/pan-end-symbolic.svg | 15 + .../data/pan-end-symbolic.svg.license | 2 + .../src/modules/keyboardq/data/pt-qt6.xml | 64 + calamares/src/modules/keyboardq/data/pt.xml | 64 + .../src/modules/keyboardq/data/ru-qt6.xml | 64 + calamares/src/modules/keyboardq/data/ru.xml | 64 + calamares/src/modules/keyboardq/data/scan.xml | 64 + .../src/modules/keyboardq/data/shift.svg | 7 + .../modules/keyboardq/data/shift.svg.license | 2 + .../src/modules/keyboardq/keyboardq-qt6.qml | 356 + .../src/modules/keyboardq/keyboardq-qt6.qrc | 29 + .../src/modules/keyboardq/keyboardq.conf | 19 + calamares/src/modules/keyboardq/keyboardq.qml | 356 + calamares/src/modules/keyboardq/keyboardq.qrc | 29 + calamares/src/modules/license/CMakeLists.txt | 18 + calamares/src/modules/license/LicensePage.cpp | 205 + calamares/src/modules/license/LicensePage.h | 100 + calamares/src/modules/license/LicensePage.ui | 172 + .../src/modules/license/LicenseViewStep.cpp | 114 + .../src/modules/license/LicenseViewStep.h | 52 + .../src/modules/license/LicenseWidget.cpp | 205 + calamares/src/modules/license/LicenseWidget.h | 45 + calamares/src/modules/license/README.md | 24 + calamares/src/modules/license/license.conf | 53 + .../src/modules/license/license.schema.yaml | 21 + calamares/src/modules/locale/CMakeLists.txt | 50 + calamares/src/modules/locale/Config.cpp | 601 ++ calamares/src/modules/locale/Config.h | 198 + .../src/modules/locale/LCLocaleDialog.cpp | 90 + calamares/src/modules/locale/LCLocaleDialog.h | 31 + .../modules/locale/LocaleConfiguration.cpp | 336 + .../src/modules/locale/LocaleConfiguration.h | 85 + calamares/src/modules/locale/LocaleNames.cpp | 90 + calamares/src/modules/locale/LocaleNames.h | 46 + calamares/src/modules/locale/LocalePage.cpp | 227 + calamares/src/modules/locale/LocalePage.h | 66 + .../src/modules/locale/LocaleViewStep.cpp | 143 + calamares/src/modules/locale/LocaleViewStep.h | 65 + .../src/modules/locale/SetTimezoneJob.cpp | 89 + calamares/src/modules/locale/SetTimezoneJob.h | 30 + calamares/src/modules/locale/Tests.cpp | 587 ++ calamares/src/modules/locale/images/bg.png | Bin 0 -> 175122 bytes .../src/modules/locale/images/bg.png.license | 2 + calamares/src/modules/locale/images/pin.png | Bin 0 -> 6191 bytes .../src/modules/locale/images/pin.png.license | 2 + .../modules/locale/images/timezone_-1.0.png | Bin 0 -> 12010 bytes .../modules/locale/images/timezone_-10.0.png | Bin 0 -> 10414 bytes .../modules/locale/images/timezone_-11.0.png | Bin 0 -> 17019 bytes .../modules/locale/images/timezone_-2.0.png | Bin 0 -> 5496 bytes .../modules/locale/images/timezone_-3.0.png | Bin 0 -> 15999 bytes .../modules/locale/images/timezone_-3.5.png | Bin 0 -> 1176 bytes .../modules/locale/images/timezone_-4.0.png | Bin 0 -> 17925 bytes .../modules/locale/images/timezone_-4.5.png | Bin 0 -> 2823 bytes .../modules/locale/images/timezone_-5.0.png | Bin 0 -> 22353 bytes .../modules/locale/images/timezone_-5.5.png | Bin 0 -> 864 bytes .../modules/locale/images/timezone_-6.0.png | Bin 0 -> 16804 bytes .../modules/locale/images/timezone_-7.0.png | Bin 0 -> 15126 bytes .../modules/locale/images/timezone_-8.0.png | Bin 0 -> 8291 bytes .../modules/locale/images/timezone_-9.0.png | Bin 0 -> 9735 bytes .../modules/locale/images/timezone_-9.5.png | Bin 0 -> 864 bytes .../modules/locale/images/timezone_0.0.png | Bin 0 -> 16289 bytes .../modules/locale/images/timezone_1.0.png | Bin 0 -> 26634 bytes .../modules/locale/images/timezone_10.0.png | Bin 0 -> 18305 bytes .../modules/locale/images/timezone_10.5.png | Bin 0 -> 775 bytes .../modules/locale/images/timezone_11.0.png | Bin 0 -> 13590 bytes .../modules/locale/images/timezone_12.0.png | Bin 0 -> 8116 bytes .../modules/locale/images/timezone_12.75.png | Bin 0 -> 867 bytes .../modules/locale/images/timezone_13.0.png | Bin 0 -> 911 bytes .../modules/locale/images/timezone_2.0.png | Bin 0 -> 18803 bytes .../modules/locale/images/timezone_3.0.png | Bin 0 -> 21322 bytes .../modules/locale/images/timezone_3.5.png | Bin 0 -> 3340 bytes .../modules/locale/images/timezone_4.0.png | Bin 0 -> 5105 bytes .../modules/locale/images/timezone_4.5.png | Bin 0 -> 2690 bytes .../modules/locale/images/timezone_5.0.png | Bin 0 -> 15038 bytes .../modules/locale/images/timezone_5.5.png | Bin 0 -> 5413 bytes .../modules/locale/images/timezone_5.75.png | Bin 0 -> 1387 bytes .../modules/locale/images/timezone_6.0.png | Bin 0 -> 8921 bytes .../modules/locale/images/timezone_6.5.png | Bin 0 -> 2958 bytes .../modules/locale/images/timezone_7.0.png | Bin 0 -> 15203 bytes .../modules/locale/images/timezone_8.0.png | Bin 0 -> 17644 bytes .../modules/locale/images/timezone_9.0.png | Bin 0 -> 16374 bytes .../modules/locale/images/timezone_9.5.png | Bin 0 -> 2964 bytes calamares/src/modules/locale/locale.conf | 131 + calamares/src/modules/locale/locale.qrc | 43 + .../src/modules/locale/locale.schema.yaml | 40 + .../modules/locale/tests/locale-data-freebsd | 79 + .../src/modules/locale/tests/locale-data-neon | 318 + .../locale/timezonewidget/TimeZoneImage.cpp | 200 + .../locale/timezonewidget/TimeZoneImage.h | 76 + .../locale/timezonewidget/timezonewidget.cpp | 197 + .../locale/timezonewidget/timezonewidget.h | 73 + calamares/src/modules/localecfg/main.py | 175 + calamares/src/modules/localecfg/module.desc | 11 + calamares/src/modules/localeq/CMakeLists.txt | 42 + .../src/modules/localeq/LocaleQmlViewStep.cpp | 97 + .../src/modules/localeq/LocaleQmlViewStep.h | 52 + calamares/src/modules/localeq/Map-qt6.qml | 287 + calamares/src/modules/localeq/Map.qml | 263 + calamares/src/modules/localeq/Offline-qt6.qml | 243 + calamares/src/modules/localeq/Offline.qml | 243 + calamares/src/modules/localeq/img/locale.svg | 5720 +++++++++++++++++ .../modules/localeq/img/locale.svg.license | 2 + calamares/src/modules/localeq/img/minus.png | Bin 0 -> 177 bytes .../src/modules/localeq/img/minus.png.license | 2 + calamares/src/modules/localeq/img/pin.svg | 60 + .../src/modules/localeq/img/pin.svg.license | 2 + calamares/src/modules/localeq/img/plus.png | Bin 0 -> 483 bytes .../src/modules/localeq/img/plus.png.license | 2 + calamares/src/modules/localeq/localeq-qt6.qml | 259 + calamares/src/modules/localeq/localeq-qt6.qrc | 11 + calamares/src/modules/localeq/localeq.conf | 100 + calamares/src/modules/localeq/localeq.qml | 259 + calamares/src/modules/localeq/localeq.qrc | 11 + .../modules/luksbootkeyfile/CMakeLists.txt | 14 + .../luksbootkeyfile/LuksBootKeyFileJob.cpp | 343 + .../luksbootkeyfile/LuksBootKeyFileJob.h | 42 + .../src/modules/luksbootkeyfile/Tests.cpp | 168 + .../luksbootkeyfile/luksbootkeyfile.conf | 14 + .../luksbootkeyfile.schema.yaml | 9 + .../luksopenswaphookcfg/CMakeLists.txt | 17 + .../modules/luksopenswaphookcfg/LOSHInfo.h | 66 + .../modules/luksopenswaphookcfg/LOSHJob.cpp | 179 + .../src/modules/luksopenswaphookcfg/LOSHJob.h | 37 + .../src/modules/luksopenswaphookcfg/Tests.cpp | 248 + .../luksopenswaphookcfg.conf | 7 + .../luksopenswaphookcfg.schema.yaml | 10 + .../src/modules/machineid/CMakeLists.txt | 15 + .../src/modules/machineid/MachineIdJob.cpp | 186 + .../src/modules/machineid/MachineIdJob.h | 62 + calamares/src/modules/machineid/Tests.cpp | 264 + calamares/src/modules/machineid/Workers.cpp | 191 + calamares/src/modules/machineid/Workers.h | 70 + .../src/modules/machineid/machineid.conf | 56 + .../modules/machineid/machineid.schema.yaml | 17 + calamares/src/modules/mkinitfs/main.py | 50 + calamares/src/modules/mkinitfs/module.desc | 7 + calamares/src/modules/mount/main.py | 395 ++ calamares/src/modules/mount/module.desc | 7 + calamares/src/modules/mount/mount.conf | 125 + calamares/src/modules/mount/mount.schema.yaml | 43 + calamares/src/modules/mount/tests/1.global | 12 + calamares/src/modules/mount/tests/1.job | 6 + calamares/src/modules/mount/tests/2.global | 8 + calamares/src/modules/mount/tests/2.job | 6 + calamares/src/modules/mount/tests/3.global | 8 + calamares/src/modules/mount/tests/3.job | 6 + calamares/src/modules/mount/tests/4.global | 11 + calamares/src/modules/mount/tests/4.job | 12 + .../src/modules/netinstall/CMakeLists.txt | 30 + calamares/src/modules/netinstall/Config.cpp | 179 + calamares/src/modules/netinstall/Config.h | 101 + .../src/modules/netinstall/LoaderQueue.cpp | 205 + .../src/modules/netinstall/LoaderQueue.h | 77 + .../src/modules/netinstall/NetInstallPage.cpp | 84 + .../src/modules/netinstall/NetInstallPage.h | 55 + .../modules/netinstall/NetInstallViewStep.cpp | 138 + .../modules/netinstall/NetInstallViewStep.h | 63 + .../src/modules/netinstall/PackageModel.cpp | 392 ++ .../src/modules/netinstall/PackageModel.h | 92 + .../modules/netinstall/PackageTreeItem.cpp | 311 + .../src/modules/netinstall/PackageTreeItem.h | 179 + calamares/src/modules/netinstall/Tests.cpp | 425 ++ .../src/modules/netinstall/groupstreeview.cpp | 36 + .../src/modules/netinstall/groupstreeview.h | 18 + .../src/modules/netinstall/netinstall.conf | 347 + .../modules/netinstall/netinstall.schema.yaml | 86 + .../src/modules/netinstall/netinstall.yaml | 218 + .../src/modules/netinstall/page_netinst.ui | 75 + .../netinstall/tests/1a-single-bad.conf | 7 + .../netinstall/tests/1a-single-empty.conf | 7 + .../netinstall/tests/1a-single-error.conf | 7 + .../netinstall/tests/1b-single-large.conf | 7 + .../netinstall/tests/1b-single-small.conf | 7 + .../src/modules/netinstall/tests/1c-none.conf | 6 + .../modules/netinstall/tests/1c-unset.conf | 5 + .../netinstall/tests/1d-fallback-bad.conf | 10 + .../netinstall/tests/1d-fallback-large.conf | 10 + .../netinstall/tests/1d-fallback-mixed.conf | 13 + .../netinstall/tests/1d-fallback-small.conf | 10 + .../modules/netinstall/tests/data-empty.yaml | 6 + .../modules/netinstall/tests/data-error.yaml | 8 + .../modules/netinstall/tests/data-large.yaml | 38 + .../modules/netinstall/tests/data-small.yaml | 17 + calamares/src/modules/networkcfg/main.py | 187 + calamares/src/modules/networkcfg/module.desc | 8 + calamares/src/modules/notesqml/CMakeLists.txt | 19 + .../src/modules/notesqml/NotesQmlViewStep.cpp | 40 + .../src/modules/notesqml/NotesQmlViewStep.h | 37 + .../notesqml/examples/notesqml.qml.example | 74 + calamares/src/modules/notesqml/notesqml.conf | 43 + calamares/src/modules/notesqml/notesqml.qml | 56 + calamares/src/modules/notesqml/notesqml.qrc | 5 + calamares/src/modules/oemid/CMakeLists.txt | 17 + calamares/src/modules/oemid/IDJob.cpp | 89 + calamares/src/modules/oemid/IDJob.h | 33 + calamares/src/modules/oemid/OEMPage.ui | 100 + calamares/src/modules/oemid/OEMViewStep.cpp | 150 + calamares/src/modules/oemid/OEMViewStep.h | 57 + calamares/src/modules/oemid/oemid.conf | 16 + .../src/modules/openrcdmcryptcfg/main.py | 81 + .../src/modules/openrcdmcryptcfg/module.desc | 7 + .../openrcdmcryptcfg/openrcdmcryptcfg.conf | 5 + .../src/modules/packagechooser/CMakeLists.txt | 52 + .../src/modules/packagechooser/Config.cpp | 360 ++ calamares/src/modules/packagechooser/Config.h | 128 + .../modules/packagechooser/ItemAppData.cpp | 225 + .../src/modules/packagechooser/ItemAppData.h | 28 + .../modules/packagechooser/ItemAppStream.cpp | 159 + .../modules/packagechooser/ItemAppStream.h | 54 + .../packagechooser/PackageChooserPage.cpp | 145 + .../packagechooser/PackageChooserPage.h | 57 + .../packagechooser/PackageChooserViewStep.cpp | 158 + .../packagechooser/PackageChooserViewStep.h | 57 + .../modules/packagechooser/PackageModel.cpp | 196 + .../src/modules/packagechooser/PackageModel.h | 135 + .../src/modules/packagechooser/Tests.cpp | 84 + calamares/src/modules/packagechooser/Tests.h | 28 + .../packagechooser/images/calamares.png | Bin 0 -> 8313 bytes .../images/calamares.png.license | 2 + .../packagechooser/images/no-selection.png | Bin 0 -> 1709 bytes .../images/no-selection.png.license | 2 + .../packagechooser/packagechooser.conf | 172 + .../modules/packagechooser/packagechooser.qrc | 6 + .../modules/packagechooser/page_package.ui | 104 + .../modules/packagechooserq/CMakeLists.txt | 56 + .../PackageChooserQmlViewStep.cpp | 86 + .../PackageChooserQmlViewStep.h | 58 + .../packagechooserq/images/libreoffice.jpg | Bin 0 -> 47916 bytes .../images/libreoffice.jpg.license | 2 + .../packagechooserq/images/no-selection.png | Bin 0 -> 188248 bytes .../images/no-selection.png.license | 2 + .../modules/packagechooserq/images/plasma.png | Bin 0 -> 35256 bytes .../packagechooserq/images/plasma.png.license | 2 + .../packagechooserq/packagechooserq-qt6.qml | 241 + .../packagechooserq/packagechooserq-qt6.qrc | 8 + .../packagechooserq/packagechooserq.conf | 66 + .../packagechooserq/packagechooserq.qml | 241 + .../packagechooserq/packagechooserq.qrc | 8 + calamares/src/modules/packages/main.py | 823 +++ calamares/src/modules/packages/module.desc | 7 + calamares/src/modules/packages/packages.conf | 214 + .../src/modules/packages/packages.schema.yaml | 54 + calamares/src/modules/packages/tests/1.global | 3 + calamares/src/modules/packages/tests/2.job | 13 + .../src/modules/packages/tests/CMakeTests.txt | 46 + .../modules/packages/tests/pm-pacman-1.yaml | 10 + .../modules/packages/tests/pm-pacman-2.yaml | 9 + .../modules/packages/tests/test-pm-pacman.py | 36 + .../src/modules/partition/CMakeLists.txt | 124 + calamares/src/modules/partition/Config.cpp | 475 ++ calamares/src/modules/partition/Config.h | 238 + .../modules/partition/PartitionViewStep.cpp | 979 +++ .../src/modules/partition/PartitionViewStep.h | 104 + calamares/src/modules/partition/README.md | 96 + .../partition/core/BootLoaderModel.cpp | 215 + .../modules/partition/core/BootLoaderModel.h | 75 + .../src/modules/partition/core/ColorUtils.cpp | 197 + .../src/modules/partition/core/ColorUtils.h | 49 + .../src/modules/partition/core/DeviceList.cpp | 197 + .../src/modules/partition/core/DeviceList.h | 40 + .../modules/partition/core/DeviceModel.cpp | 150 + .../src/modules/partition/core/DeviceModel.h | 53 + .../partition/core/DirFSRestrictLayout.cpp | 231 + .../partition/core/DirFSRestrictLayout.h | 87 + .../src/modules/partition/core/KPMHelpers.cpp | 339 + .../src/modules/partition/core/KPMHelpers.h | 167 + .../modules/partition/core/OsproberEntry.cpp | 63 + .../modules/partition/core/OsproberEntry.h | 67 + .../src/modules/partition/core/PartUtils.cpp | 636 ++ .../src/modules/partition/core/PartUtils.h | 154 + .../partition/core/PartitionActions.cpp | 284 + .../modules/partition/core/PartitionActions.h | 95 + .../partition/core/PartitionCoreModule.cpp | 1223 ++++ .../partition/core/PartitionCoreModule.h | 284 + .../modules/partition/core/PartitionInfo.cpp | 118 + .../modules/partition/core/PartitionInfo.h | 62 + .../partition/core/PartitionLayout.cpp | 378 ++ .../modules/partition/core/PartitionLayout.h | 131 + .../modules/partition/core/PartitionModel.cpp | 338 + .../modules/partition/core/PartitionModel.h | 116 + .../src/modules/partition/core/SizeUtils.h | 28 + .../modules/partition/gui/BootInfoWidget.cpp | 94 + .../modules/partition/gui/BootInfoWidget.h | 32 + .../src/modules/partition/gui/ChoicePage.cpp | 1821 ++++++ .../src/modules/partition/gui/ChoicePage.h | 181 + .../src/modules/partition/gui/ChoicePage.ui | 224 + .../partition/gui/CreatePartitionDialog.cpp | 367 ++ .../partition/gui/CreatePartitionDialog.h | 102 + .../partition/gui/CreatePartitionDialog.ui | 344 + .../gui/CreatePartitionTableDialog.ui | 142 + .../partition/gui/CreateVolumeGroupDialog.cpp | 47 + .../partition/gui/CreateVolumeGroupDialog.h | 33 + .../partition/gui/DeviceInfoWidget.cpp | 163 + .../modules/partition/gui/DeviceInfoWidget.h | 37 + .../gui/EditExistingPartitionDialog.cpp | 393 ++ .../gui/EditExistingPartitionDialog.h | 66 + .../gui/EditExistingPartitionDialog.ui | 289 + .../modules/partition/gui/EncryptWidget.cpp | 228 + .../src/modules/partition/gui/EncryptWidget.h | 72 + .../modules/partition/gui/EncryptWidget.ui | 100 + .../gui/ListPhysicalVolumeWidgetItem.cpp | 29 + .../gui/ListPhysicalVolumeWidgetItem.h | 29 + .../partition/gui/PartitionBarsView.cpp | 535 ++ .../modules/partition/gui/PartitionBarsView.h | 91 + .../partition/gui/PartitionDialogHelpers.cpp | 201 + .../partition/gui/PartitionDialogHelpers.h | 88 + .../partition/gui/PartitionLabelsView.cpp | 617 ++ .../partition/gui/PartitionLabelsView.h | 84 + .../modules/partition/gui/PartitionPage.cpp | 698 ++ .../src/modules/partition/gui/PartitionPage.h | 90 + .../modules/partition/gui/PartitionPage.ui | 243 + .../partition/gui/PartitionSizeController.cpp | 221 + .../partition/gui/PartitionSizeController.h | 72 + .../partition/gui/PartitionSplitterWidget.cpp | 636 ++ .../partition/gui/PartitionSplitterWidget.h | 94 + .../gui/PartitionViewSelectionFilter.h | 19 + .../partition/gui/ResizeVolumeGroupDialog.cpp | 59 + .../partition/gui/ResizeVolumeGroupDialog.h | 35 + .../modules/partition/gui/ScanningDialog.cpp | 77 + .../modules/partition/gui/ScanningDialog.h | 43 + .../partition/gui/VolumeGroupBaseDialog.cpp | 185 + .../partition/gui/VolumeGroupBaseDialog.h | 71 + .../partition/gui/VolumeGroupBaseDialog.ui | 210 + .../partition/jobs/AutoMountManagementJob.cpp | 40 + .../partition/jobs/AutoMountManagementJob.h | 42 + .../jobs/ChangeFilesystemLabelJob.cpp | 86 + .../partition/jobs/ChangeFilesystemLabelJob.h | 40 + .../modules/partition/jobs/ClearMountsJob.cpp | 417 ++ .../modules/partition/jobs/ClearMountsJob.h | 60 + .../partition/jobs/ClearTempMountsJob.cpp | 76 + .../partition/jobs/ClearTempMountsJob.h | 31 + .../partition/jobs/CreatePartitionJob.cpp | 283 + .../partition/jobs/CreatePartitionJob.h | 44 + .../jobs/CreatePartitionTableJob.cpp | 106 + .../partition/jobs/CreatePartitionTableJob.h | 48 + .../partition/jobs/CreateVolumeGroupJob.cpp | 70 + .../partition/jobs/CreateVolumeGroupJob.h | 42 + .../jobs/DeactivateVolumeGroupJob.cpp | 52 + .../partition/jobs/DeactivateVolumeGroupJob.h | 34 + .../partition/jobs/DeletePartitionJob.cpp | 120 + .../partition/jobs/DeletePartitionJob.h | 44 + .../partition/jobs/FillGlobalStorageJob.cpp | 400 ++ .../partition/jobs/FillGlobalStorageJob.h | 49 + .../partition/jobs/FormatPartitionJob.cpp | 85 + .../partition/jobs/FormatPartitionJob.h | 42 + .../modules/partition/jobs/PartitionJob.cpp | 29 + .../src/modules/partition/jobs/PartitionJob.h | 43 + .../partition/jobs/RemoveVolumeGroupJob.cpp | 47 + .../partition/jobs/RemoveVolumeGroupJob.h | 35 + .../partition/jobs/ResizePartitionJob.cpp | 90 + .../partition/jobs/ResizePartitionJob.h | 47 + .../partition/jobs/ResizeVolumeGroupJob.cpp | 89 + .../partition/jobs/ResizeVolumeGroupJob.h | 43 + .../partition/jobs/SetPartitionFlagsJob.cpp | 150 + .../partition/jobs/SetPartitionFlagsJob.h | 43 + .../src/modules/partition/partition.conf | 392 ++ .../modules/partition/partition.schema.yaml | 55 + .../modules/partition/tests/1a-legacy.conf | 2 + .../modules/partition/tests/1b-legacy.conf | 2 + .../modules/partition/tests/2a-legacy.conf | 9 + .../modules/partition/tests/2b-modern.conf | 6 + .../src/modules/partition/tests/2c-mixed.conf | 7 + .../modules/partition/tests/2d-overlap.conf | 9 + .../partition/tests/3a-min-too-large.conf | 5 + .../partition/tests/AutoMountTests.cpp | 88 + .../modules/partition/tests/CMakeLists.txt | 80 + .../partition/tests/ClearMountsJobTests.cpp | 68 + .../partition/tests/ClearMountsJobTests.h | 25 + .../modules/partition/tests/ConfigTests.cpp | 277 + .../partition/tests/CreateLayoutsTests.cpp | 156 + .../partition/tests/CreateLayoutsTests.h | 39 + .../modules/partition/tests/DevicesTests.cpp | 90 + .../partition/tests/PartitionJobTests.cpp | 443 ++ .../partition/tests/PartitionJobTests.h | 66 + .../src/modules/plasmalnf/CMakeLists.txt | 53 + calamares/src/modules/plasmalnf/Config.cpp | 167 + calamares/src/modules/plasmalnf/Config.h | 77 + .../src/modules/plasmalnf/PlasmaLnfJob.cpp | 72 + .../src/modules/plasmalnf/PlasmaLnfJob.h | 35 + .../src/modules/plasmalnf/PlasmaLnfPage.cpp | 117 + .../src/modules/plasmalnf/PlasmaLnfPage.h | 39 + .../modules/plasmalnf/PlasmaLnfViewStep.cpp | 99 + .../src/modules/plasmalnf/PlasmaLnfViewStep.h | 51 + calamares/src/modules/plasmalnf/ThemeInfo.cpp | 316 + calamares/src/modules/plasmalnf/ThemeInfo.h | 75 + .../src/modules/plasmalnf/page_plasmalnf.qrc | 5 + .../src/modules/plasmalnf/page_plasmalnf.ui | 37 + .../src/modules/plasmalnf/plasmalnf.conf | 83 + .../src/modules/plasmalnf/view-preview.png | Bin 0 -> 560 bytes .../plasmalnf/view-preview.png.license | 2 + .../src/modules/plasmalnf/view-preview.svg | 13 + .../plasmalnf/view-preview.svg.license | 2 + calamares/src/modules/plymouthcfg/main.py | 63 + calamares/src/modules/plymouthcfg/module.desc | 7 + .../src/modules/plymouthcfg/plymouthcfg.conf | 31 + .../plymouthcfg/plymouthcfg.schema.yaml | 9 + .../src/modules/preservefiles/CMakeLists.txt | 17 + calamares/src/modules/preservefiles/Item.cpp | 159 + calamares/src/modules/preservefiles/Item.h | 74 + .../modules/preservefiles/PreserveFiles.cpp | 120 + .../src/modules/preservefiles/PreserveFiles.h | 39 + calamares/src/modules/preservefiles/Tests.cpp | 93 + .../modules/preservefiles/preservefiles.conf | 70 + .../preservefiles/preservefiles.schema.yaml | 37 + .../modules/preservefiles/tests/1a-log.conf | 7 + .../preservefiles/tests/1b-config.conf | 6 + .../modules/preservefiles/tests/1c-src.conf | 6 + .../preservefiles/tests/1d-filename.conf | 6 + .../modules/preservefiles/tests/1e-empty.conf | 3 + .../modules/preservefiles/tests/1f-bad.conf | 4 + calamares/src/modules/rawfs/main.py | 183 + calamares/src/modules/rawfs/module.desc | 9 + calamares/src/modules/rawfs/rawfs.conf | 32 + calamares/src/modules/rawfs/tests/1.global | 11 + calamares/src/modules/rawfs/tests/1.job | 17 + .../src/modules/rawfs/tests/CMakeTests.txt | 14 + .../src/modules/removeuser/CMakeLists.txt | 12 + .../src/modules/removeuser/RemoveUserJob.cpp | 62 + .../src/modules/removeuser/RemoveUserJob.h | 40 + .../src/modules/removeuser/removeuser.conf | 13 + .../modules/removeuser/removeuser.schema.yaml | 10 + calamares/src/modules/services-openrc/main.py | 131 + .../src/modules/services-openrc/module.desc | 7 + .../services-openrc/services-openrc.conf | 49 + .../src/modules/services-systemd/main.py | 86 + .../src/modules/services-systemd/module.desc | 7 + .../services-systemd/services-systemd.conf | 54 + .../services-systemd.schema.yaml | 21 + .../src/modules/shellprocess/CMakeLists.txt | 14 + .../modules/shellprocess/ShellProcessJob.cpp | 91 + .../modules/shellprocess/ShellProcessJob.h | 46 + calamares/src/modules/shellprocess/Tests.cpp | 206 + calamares/src/modules/shellprocess/Tests.h | 38 + .../modules/shellprocess/shellprocess.conf | 146 + .../shellprocess/shellprocess.schema.yaml | 65 + calamares/src/modules/summary/CMakeLists.txt | 18 + calamares/src/modules/summary/Config.cpp | 92 + calamares/src/modules/summary/Config.h | 63 + .../src/modules/summary/SummaryModel.cpp | 74 + calamares/src/modules/summary/SummaryModel.h | 67 + calamares/src/modules/summary/SummaryPage.cpp | 176 + calamares/src/modules/summary/SummaryPage.h | 61 + .../src/modules/summary/SummaryViewStep.cpp | 97 + .../src/modules/summary/SummaryViewStep.h | 51 + calamares/src/modules/summaryq/CMakeLists.txt | 28 + .../modules/summaryq/SummaryQmlViewStep.cpp | 73 + .../src/modules/summaryq/SummaryQmlViewStep.h | 49 + .../src/modules/summaryq/img/keyboard.svg | 22 + .../modules/summaryq/img/keyboard.svg.license | 2 + .../src/modules/summaryq/img/lokalize.svg | 39 + .../modules/summaryq/img/lokalize.svg.license | 2 + .../src/modules/summaryq/summaryq-qt6.qml | 111 + .../src/modules/summaryq/summaryq-qt6.qrc | 7 + calamares/src/modules/summaryq/summaryq.qml | 112 + calamares/src/modules/summaryq/summaryq.qrc | 7 + calamares/src/modules/tracking/CMakeLists.txt | 23 + calamares/src/modules/tracking/Config.cpp | 250 + calamares/src/modules/tracking/Config.h | 186 + calamares/src/modules/tracking/Tests.cpp | 59 + .../src/modules/tracking/TrackingJobs.cpp | 301 + calamares/src/modules/tracking/TrackingJobs.h | 37 + .../src/modules/tracking/TrackingPage.cpp | 157 + calamares/src/modules/tracking/TrackingPage.h | 69 + calamares/src/modules/tracking/TrackingType.h | 26 + .../src/modules/tracking/TrackingViewStep.cpp | 115 + .../src/modules/tracking/TrackingViewStep.h | 57 + .../src/modules/tracking/level-install.svg | 194 + .../tracking/level-install.svg.license | 2 + .../src/modules/tracking/level-machine.svg | 271 + .../tracking/level-machine.svg.license | 2 + calamares/src/modules/tracking/level-none.svg | 212 + .../modules/tracking/level-none.svg.license | 2 + calamares/src/modules/tracking/level-user.svg | 224 + .../modules/tracking/level-user.svg.license | 2 + .../modules/tracking/page_trackingstep.qrc | 9 + .../src/modules/tracking/page_trackingstep.ui | 310 + calamares/src/modules/tracking/tracking.conf | 105 + calamares/src/modules/umount/CMakeLists.txt | 15 + calamares/src/modules/umount/Tests.cpp | 52 + calamares/src/modules/umount/UmountJob.cpp | 173 + calamares/src/modules/umount/UmountJob.h | 41 + calamares/src/modules/umount/umount.conf | 14 + .../src/modules/umount/umount.schema.yaml | 9 + calamares/src/modules/unpackfs/main.py | 542 ++ calamares/src/modules/unpackfs/module.desc | 10 + calamares/src/modules/unpackfs/runtests.sh | 37 + calamares/src/modules/unpackfs/tests/1.global | 4 + calamares/src/modules/unpackfs/tests/2.global | 4 + calamares/src/modules/unpackfs/tests/3.global | 4 + calamares/src/modules/unpackfs/tests/3.job | 4 + calamares/src/modules/unpackfs/tests/4.global | 4 + calamares/src/modules/unpackfs/tests/4.job | 10 + calamares/src/modules/unpackfs/tests/5.global | 4 + calamares/src/modules/unpackfs/tests/5.job | 7 + calamares/src/modules/unpackfs/tests/6.global | 4 + calamares/src/modules/unpackfs/tests/6.job | 7 + calamares/src/modules/unpackfs/tests/7.global | 4 + calamares/src/modules/unpackfs/tests/7.job | 7 + calamares/src/modules/unpackfs/tests/8.global | 6 + calamares/src/modules/unpackfs/tests/8.job | 7 + calamares/src/modules/unpackfs/tests/9.global | 5 + calamares/src/modules/unpackfs/tests/9.job | 8 + calamares/src/modules/unpackfs/unpackfs.conf | 128 + .../src/modules/unpackfs/unpackfs.schema.yaml | 26 + .../src/modules/unpackfsc/CMakeLists.txt | 16 + .../src/modules/unpackfsc/ErofsRunner.cpp | 109 + calamares/src/modules/unpackfsc/ErofsRunner.h | 38 + .../modules/unpackfsc/FSArchiverRunner.cpp | 117 + .../src/modules/unpackfsc/FSArchiverRunner.h | 59 + calamares/src/modules/unpackfsc/Runners.cpp | 38 + calamares/src/modules/unpackfsc/Runners.h | 48 + .../src/modules/unpackfsc/TarballRunner.cpp | 86 + .../src/modules/unpackfsc/TarballRunner.h | 36 + .../src/modules/unpackfsc/UnpackFSCJob.cpp | 199 + .../src/modules/unpackfsc/UnpackFSCJob.h | 52 + .../src/modules/unpackfsc/UnsquashRunner.cpp | 100 + .../src/modules/unpackfsc/UnsquashRunner.h | 38 + .../src/modules/unpackfsc/tests/1.global | 2 + calamares/src/modules/unpackfsc/tests/1.job | 4 + .../src/modules/unpackfsc/unpackfsc.conf | 74 + .../modules/unpackfsc/unpackfsc.schema.yaml | 22 + .../src/modules/users/ActiveDirectoryJob.cpp | 84 + .../src/modules/users/ActiveDirectoryJob.h | 34 + calamares/src/modules/users/CMakeLists.txt | 142 + .../src/modules/users/CheckPWQuality.cpp | 400 ++ calamares/src/modules/users/CheckPWQuality.h | 80 + calamares/src/modules/users/Config.cpp | 1113 ++++ calamares/src/modules/users/Config.h | 386 ++ calamares/src/modules/users/CreateUserJob.cpp | 172 + calamares/src/modules/users/CreateUserJob.h | 32 + calamares/src/modules/users/MiscJobs.cpp | 210 + calamares/src/modules/users/MiscJobs.h | 50 + .../src/modules/users/SetHostNameJob.cpp | 161 + calamares/src/modules/users/SetHostNameJob.h | 33 + .../src/modules/users/SetPasswordJob.cpp | 112 + calamares/src/modules/users/SetPasswordJob.h | 34 + .../modules/users/TestGroupInformation.cpp | 177 + .../src/modules/users/TestPasswordJob.cpp | 55 + .../src/modules/users/TestSetHostNameJob.cpp | 163 + calamares/src/modules/users/Tests.cpp | 589 ++ calamares/src/modules/users/UsersPage.cpp | 313 + calamares/src/modules/users/UsersPage.h | 56 + calamares/src/modules/users/UsersViewStep.cpp | 119 + calamares/src/modules/users/UsersViewStep.h | 56 + .../src/modules/users/images/invalid.png | Bin 0 -> 7822 bytes .../modules/users/images/invalid.png.license | 2 + calamares/src/modules/users/images/valid.png | Bin 0 -> 7284 bytes .../modules/users/images/valid.png.license | 2 + calamares/src/modules/users/page_usersetup.ui | 710 ++ calamares/src/modules/users/tests/3-wing.conf | 5 + .../src/modules/users/tests/4-audio.conf | 9 + .../src/modules/users/tests/5-issue-1523.conf | 14 + .../modules/users/tests/6a-issue-1672.conf | 7 + .../modules/users/tests/6b-issue-1672.conf | 7 + .../modules/users/tests/6c-issue-1672.conf | 7 + .../modules/users/tests/6d-issue-1672.conf | 7 + .../modules/users/tests/6e-issue-1672.conf | 7 + .../src/modules/users/tests/7an-shell.conf | 8 + .../src/modules/users/tests/7ao-shell.conf | 7 + .../src/modules/users/tests/7bn-shell.conf | 8 + .../src/modules/users/tests/7bo-shell.conf | 7 + .../src/modules/users/tests/7cn-shell.conf | 8 + .../src/modules/users/tests/7co-shell.conf | 7 + .../src/modules/users/tests/7dn-shell.conf | 8 + .../src/modules/users/tests/7do-shell.conf | 7 + .../src/modules/users/tests/7en-shell.conf | 8 + .../src/modules/users/tests/7eo-shell.conf | 7 + .../src/modules/users/tests/7fb-shell.conf | 10 + .../src/modules/users/tests/7fn-shell.conf | 10 + .../src/modules/users/tests/7fo-shell.conf | 10 + .../modules/users/tests/8a-issue-2362.conf | 11 + .../modules/users/tests/8b-issue-2362.conf | 13 + .../modules/users/tests/8c-issue-2362.conf | 11 + .../modules/users/tests/8d-issue-2362.conf | 11 + .../modules/users/tests/8e-issue-2362.conf | 11 + .../modules/users/tests/8f-issue-2362.conf | 11 + .../modules/users/tests/8g-issue-2362.conf | 12 + .../modules/users/tests/8h-issue-2362.conf | 12 + calamares/src/modules/users/users.conf | 312 + calamares/src/modules/users/users.qrc | 6 + calamares/src/modules/users/users.schema.yaml | 75 + calamares/src/modules/usersq/CMakeLists.txt | 51 + .../src/modules/usersq/UsersQmlViewStep.cpp | 79 + .../src/modules/usersq/UsersQmlViewStep.h | 54 + calamares/src/modules/usersq/usersq-qt6.qml | 425 ++ calamares/src/modules/usersq/usersq-qt6.qrc | 5 + calamares/src/modules/usersq/usersq.conf | 44 + calamares/src/modules/usersq/usersq.qml | 426 ++ calamares/src/modules/usersq/usersq.qrc | 5 + calamares/src/modules/welcome/CMakeLists.txt | 46 + calamares/src/modules/welcome/Config.cpp | 438 ++ calamares/src/modules/welcome/Config.h | 143 + calamares/src/modules/welcome/Tests.cpp | 166 + calamares/src/modules/welcome/WelcomePage.cpp | 227 + calamares/src/modules/welcome/WelcomePage.h | 83 + calamares/src/modules/welcome/WelcomePage.ui | 230 + .../src/modules/welcome/WelcomeViewStep.cpp | 105 + .../src/modules/welcome/WelcomeViewStep.h | 73 + .../welcome/checker/CheckerContainer.cpp | 98 + .../welcome/checker/CheckerContainer.h | 56 + .../welcome/checker/GeneralRequirements.cpp | 517 ++ .../welcome/checker/GeneralRequirements.h | 44 + .../welcome/checker/ResultDelegate.cpp | 102 + .../modules/welcome/checker/ResultDelegate.h | 37 + .../welcome/checker/ResultsListWidget.cpp | 121 + .../welcome/checker/ResultsListWidget.h | 46 + .../modules/welcome/checker/partman_devices.c | 143 + .../modules/welcome/checker/partman_devices.h | 24 + .../modules/welcome/language-icon-128px.png | Bin 0 -> 4634 bytes .../welcome/language-icon-128px.png.license | 2 + .../modules/welcome/language-icon-48px.png | Bin 0 -> 2315 bytes .../welcome/language-icon-48px.png.license | 2 + .../welcome/tests/1a-checkinternet.conf | 5 + .../welcome/tests/1b-checkinternet.conf | 6 + .../welcome/tests/1c-checkinternet.conf | 7 + .../welcome/tests/1d-checkinternet.conf | 7 + .../welcome/tests/1e-checkinternet.conf | 7 + .../welcome/tests/1f-checkinternet.conf | 10 + .../welcome/tests/1g-checkinternet.conf | 7 + .../welcome/tests/1h-checkinternet.conf | 11 + calamares/src/modules/welcome/welcome.conf | 138 + calamares/src/modules/welcome/welcome.qrc | 6 + .../src/modules/welcome/welcome.schema.yaml | 38 + calamares/src/modules/welcomeq/CMakeLists.txt | 48 + .../src/modules/welcomeq/Recommended.qml | 91 + .../src/modules/welcomeq/Requirements.qml | 108 + .../modules/welcomeq/WelcomeQmlViewStep.cpp | 94 + .../src/modules/welcomeq/WelcomeQmlViewStep.h | 70 + .../welcomeq/img/chevron-left-solid.svg | 1 + .../img/chevron-left-solid.svg.license | 2 + .../welcomeq/img/language-icon-48px.png | Bin 0 -> 2315 bytes .../img/language-icon-48px.png.license | 2 + calamares/src/modules/welcomeq/img/squid.png | Bin 0 -> 8313 bytes .../modules/welcomeq/img/squid.png.license | 2 + .../src/modules/welcomeq/release_notes.qml | 94 + .../src/modules/welcomeq/welcomeq-qt6.qml | 169 + .../src/modules/welcomeq/welcomeq-qt6.qrc | 11 + calamares/src/modules/welcomeq/welcomeq.conf | 40 + calamares/src/modules/welcomeq/welcomeq.qml | 169 + calamares/src/modules/welcomeq/welcomeq.qrc | 11 + calamares/src/modules/zfs/CMakeLists.txt | 12 + calamares/src/modules/zfs/README.md | 21 + calamares/src/modules/zfs/ZfsJob.cpp | 371 ++ calamares/src/modules/zfs/ZfsJob.h | 89 + calamares/src/modules/zfs/zfs.conf | 45 + calamares/src/modules/zfs/zfs.schema.yaml | 24 + calamares/src/modules/zfshostid/main.py | 44 + calamares/src/modules/zfshostid/module.desc | 8 + .../modules/zfshostid/zfshostid.schema.yaml | 7 + calamares/src/qml/CMakeLists.txt | 50 + .../src/qml/calamares-qt5/CMakeLists.txt | 1 + .../calamares-qt5/slideshow/BackButton.qml | 15 + .../calamares-qt5/slideshow/ForwardButton.qml | 14 + .../qml/calamares-qt5/slideshow/NavButton.qml | 59 + .../calamares-qt5/slideshow/Presentation.qml | 243 + .../src/qml/calamares-qt5/slideshow/Slide.qml | 206 + .../calamares-qt5/slideshow/SlideCounter.qml | 29 + .../src/qml/calamares-qt5/slideshow/qmldir | 10 + .../calamares-qt5/slideshow/qmldir.license | 2 + .../src/qml/calamares-qt6/CMakeLists.txt | 1 + .../calamares-qt6/slideshow/BackButton.qml | 15 + .../calamares-qt6/slideshow/ForwardButton.qml | 14 + .../qml/calamares-qt6/slideshow/NavButton.qml | 59 + .../calamares-qt6/slideshow/Presentation.qml | 238 + .../src/qml/calamares-qt6/slideshow/Slide.qml | 206 + .../calamares-qt6/slideshow/SlideCounter.qml | 29 + .../src/qml/calamares-qt6/slideshow/qmldir | 10 + .../calamares-qt6/slideshow/qmldir.license | 2 + 1105 files changed, 120938 insertions(+), 4 deletions(-) create mode 100755 1-get-all-alci-gits-v1.sh create mode 100644 calamares/src/CMakeLists.txt create mode 100644 calamares/src/branding/CMakeLists.txt create mode 100644 calamares/src/branding/README.md create mode 100644 calamares/src/branding/default/banner.png create mode 100644 calamares/src/branding/default/banner.png.license create mode 100644 calamares/src/branding/default/branding.desc create mode 100644 calamares/src/branding/default/lang/calamares-default_ar.ts create mode 100644 calamares/src/branding/default/lang/calamares-default_en.ts create mode 100644 calamares/src/branding/default/lang/calamares-default_eo.ts create mode 100644 calamares/src/branding/default/lang/calamares-default_fr.ts create mode 100644 calamares/src/branding/default/lang/calamares-default_nl.ts create mode 100644 calamares/src/branding/default/languages.png create mode 100644 calamares/src/branding/default/languages.png.license create mode 100644 calamares/src/branding/default/show.qml create mode 100644 calamares/src/branding/default/squid.png create mode 100644 calamares/src/branding/default/squid.png.license create mode 100644 calamares/src/branding/default/stylesheet.qss create mode 100644 calamares/src/calamares/CMakeLists.txt create mode 100644 calamares/src/calamares/CalamaresApplication.cpp create mode 100644 calamares/src/calamares/CalamaresApplication.h create mode 100644 calamares/src/calamares/CalamaresWindow.cpp create mode 100644 calamares/src/calamares/CalamaresWindow.h create mode 100644 calamares/src/calamares/DebugWindow.cpp create mode 100644 calamares/src/calamares/DebugWindow.h create mode 100644 calamares/src/calamares/DebugWindow.ui create mode 100644 calamares/src/calamares/VariantModel.cpp create mode 100644 calamares/src/calamares/VariantModel.h create mode 100644 calamares/src/calamares/calamares-navigation.qml create mode 100644 calamares/src/calamares/calamares-sidebar.qml create mode 100644 calamares/src/calamares/calamares.qrc create mode 100644 calamares/src/calamares/main.cpp create mode 100644 calamares/src/calamares/progresstree/ProgressTreeDelegate.cpp create mode 100644 calamares/src/calamares/progresstree/ProgressTreeDelegate.h create mode 100644 calamares/src/calamares/progresstree/ProgressTreeView.cpp create mode 100644 calamares/src/calamares/progresstree/ProgressTreeView.h create mode 100644 calamares/src/calamares/test_conf.cpp create mode 100644 calamares/src/calamares/testmain.cpp create mode 100644 calamares/src/libcalamares/CMakeLists.txt create mode 100644 calamares/src/libcalamares/CalamaresAbout.cpp create mode 100644 calamares/src/libcalamares/CalamaresAbout.h create mode 100644 calamares/src/libcalamares/CalamaresConfig.h.in create mode 100644 calamares/src/libcalamares/CalamaresVersion.h.in create mode 100644 calamares/src/libcalamares/CalamaresVersionX.h.in create mode 100644 calamares/src/libcalamares/CppJob.cpp create mode 100644 calamares/src/libcalamares/CppJob.h create mode 100644 calamares/src/libcalamares/DllMacro.h create mode 100644 calamares/src/libcalamares/GlobalStorage.cpp create mode 100644 calamares/src/libcalamares/GlobalStorage.h create mode 100644 calamares/src/libcalamares/Job.cpp create mode 100644 calamares/src/libcalamares/Job.h create mode 100644 calamares/src/libcalamares/JobExample.cpp create mode 100644 calamares/src/libcalamares/JobExample.h create mode 100644 calamares/src/libcalamares/JobQueue.cpp create mode 100644 calamares/src/libcalamares/JobQueue.h create mode 100644 calamares/src/libcalamares/ProcessJob.cpp create mode 100644 calamares/src/libcalamares/ProcessJob.h create mode 100644 calamares/src/libcalamares/Settings.cpp create mode 100644 calamares/src/libcalamares/Settings.h create mode 100644 calamares/src/libcalamares/Tests.cpp create mode 100644 calamares/src/libcalamares/compat/CheckBox.h create mode 100644 calamares/src/libcalamares/compat/Mutex.h create mode 100644 calamares/src/libcalamares/compat/Size.h create mode 100644 calamares/src/libcalamares/compat/Variant.h create mode 100644 calamares/src/libcalamares/compat/Xml.h create mode 100644 calamares/src/libcalamares/geoip/GeoIPFixed.cpp create mode 100644 calamares/src/libcalamares/geoip/GeoIPFixed.h create mode 100644 calamares/src/libcalamares/geoip/GeoIPJSON.cpp create mode 100644 calamares/src/libcalamares/geoip/GeoIPJSON.h create mode 100644 calamares/src/libcalamares/geoip/GeoIPTests.cpp create mode 100644 calamares/src/libcalamares/geoip/GeoIPTests.h create mode 100644 calamares/src/libcalamares/geoip/GeoIPXML.cpp create mode 100644 calamares/src/libcalamares/geoip/GeoIPXML.h create mode 100644 calamares/src/libcalamares/geoip/Handler.cpp create mode 100644 calamares/src/libcalamares/geoip/Handler.h create mode 100644 calamares/src/libcalamares/geoip/Interface.cpp create mode 100644 calamares/src/libcalamares/geoip/Interface.h create mode 100644 calamares/src/libcalamares/geoip/test_geoip.cpp create mode 100644 calamares/src/libcalamares/locale/CountryData_p.cpp create mode 100644 calamares/src/libcalamares/locale/Global.cpp create mode 100644 calamares/src/libcalamares/locale/Global.h create mode 100644 calamares/src/libcalamares/locale/Lookup.cpp create mode 100644 calamares/src/libcalamares/locale/Lookup.h create mode 100644 calamares/src/libcalamares/locale/Tests.cpp create mode 100644 calamares/src/libcalamares/locale/TimeZone.cpp create mode 100644 calamares/src/libcalamares/locale/TimeZone.h create mode 100644 calamares/src/libcalamares/locale/TranslatableConfiguration.cpp create mode 100644 calamares/src/libcalamares/locale/TranslatableConfiguration.h create mode 100644 calamares/src/libcalamares/locale/TranslatableString.cpp create mode 100644 calamares/src/libcalamares/locale/TranslatableString.h create mode 100644 calamares/src/libcalamares/locale/Translation.cpp create mode 100644 calamares/src/libcalamares/locale/Translation.h create mode 100644 calamares/src/libcalamares/locale/TranslationsModel.cpp create mode 100644 calamares/src/libcalamares/locale/TranslationsModel.h create mode 100644 calamares/src/libcalamares/locale/ZoneData_p.cxxtr create mode 100644 calamares/src/libcalamares/locale/cldr-extractor.py create mode 100644 calamares/src/libcalamares/locale/zone-extractor.py create mode 100644 calamares/src/libcalamares/modulesystem/Actions.h create mode 100644 calamares/src/libcalamares/modulesystem/Config.cpp create mode 100644 calamares/src/libcalamares/modulesystem/Config.h create mode 100644 calamares/src/libcalamares/modulesystem/Descriptor.cpp create mode 100644 calamares/src/libcalamares/modulesystem/Descriptor.h create mode 100644 calamares/src/libcalamares/modulesystem/InstanceKey.cpp create mode 100644 calamares/src/libcalamares/modulesystem/InstanceKey.h create mode 100644 calamares/src/libcalamares/modulesystem/Module.cpp create mode 100644 calamares/src/libcalamares/modulesystem/Module.h create mode 100644 calamares/src/libcalamares/modulesystem/Preset.cpp create mode 100644 calamares/src/libcalamares/modulesystem/Preset.h create mode 100644 calamares/src/libcalamares/modulesystem/Requirement.h create mode 100644 calamares/src/libcalamares/modulesystem/RequirementsChecker.cpp create mode 100644 calamares/src/libcalamares/modulesystem/RequirementsChecker.h create mode 100644 calamares/src/libcalamares/modulesystem/RequirementsModel.cpp create mode 100644 calamares/src/libcalamares/modulesystem/RequirementsModel.h create mode 100644 calamares/src/libcalamares/modulesystem/Tests.cpp create mode 100644 calamares/src/libcalamares/network/Manager.cpp create mode 100644 calamares/src/libcalamares/network/Manager.h create mode 100644 calamares/src/libcalamares/network/Tests.cpp create mode 100644 calamares/src/libcalamares/network/Tests.h create mode 100644 calamares/src/libcalamares/packages/Globals.cpp create mode 100644 calamares/src/libcalamares/packages/Globals.h create mode 100644 calamares/src/libcalamares/packages/Tests.cpp create mode 100644 calamares/src/libcalamares/partition/AutoMount.cpp create mode 100644 calamares/src/libcalamares/partition/AutoMount.h create mode 100644 calamares/src/libcalamares/partition/FileSystem.cpp create mode 100644 calamares/src/libcalamares/partition/FileSystem.h create mode 100644 calamares/src/libcalamares/partition/Global.cpp create mode 100644 calamares/src/libcalamares/partition/Global.h create mode 100644 calamares/src/libcalamares/partition/KPMHelper.h create mode 100644 calamares/src/libcalamares/partition/KPMManager.cpp create mode 100644 calamares/src/libcalamares/partition/KPMManager.h create mode 100644 calamares/src/libcalamares/partition/KPMTests.cpp create mode 100644 calamares/src/libcalamares/partition/Mount.cpp create mode 100644 calamares/src/libcalamares/partition/Mount.h create mode 100644 calamares/src/libcalamares/partition/PartitionIterator.cpp create mode 100644 calamares/src/libcalamares/partition/PartitionIterator.h create mode 100644 calamares/src/libcalamares/partition/PartitionQuery.cpp create mode 100644 calamares/src/libcalamares/partition/PartitionQuery.h create mode 100644 calamares/src/libcalamares/partition/PartitionSize.cpp create mode 100644 calamares/src/libcalamares/partition/PartitionSize.h create mode 100644 calamares/src/libcalamares/partition/Sync.cpp create mode 100644 calamares/src/libcalamares/partition/Sync.h create mode 100644 calamares/src/libcalamares/partition/Tests.cpp create mode 100644 calamares/src/libcalamares/partition/calautomount.cpp create mode 100644 calamares/src/libcalamares/pybind11/Api.cpp create mode 100644 calamares/src/libcalamares/pybind11/Api.h create mode 100644 calamares/src/libcalamares/pybind11/Pybind11Helpers.h create mode 100644 calamares/src/libcalamares/pybind11/PythonJob.cpp create mode 100644 calamares/src/libcalamares/pybind11/PythonJob.h create mode 100644 calamares/src/libcalamares/pybind11/PythonTypes.h create mode 100644 calamares/src/libcalamares/pyboost/PythonHelper.cpp create mode 100644 calamares/src/libcalamares/pyboost/PythonHelper.h create mode 100644 calamares/src/libcalamares/pyboost/PythonJob.cpp create mode 100644 calamares/src/libcalamares/pyboost/PythonJob.h create mode 100644 calamares/src/libcalamares/pyboost/PythonJobApi.cpp create mode 100644 calamares/src/libcalamares/pyboost/PythonJobApi.h create mode 100644 calamares/src/libcalamares/pyboost/PythonTypes.h create mode 100644 calamares/src/libcalamares/python/Api.cpp create mode 100644 calamares/src/libcalamares/python/Api.h create mode 100644 calamares/src/libcalamares/python/Variant.cpp create mode 100644 calamares/src/libcalamares/python/Variant.h create mode 100644 calamares/src/libcalamares/testdata/localetest_nl.ts create mode 100644 calamares/src/libcalamares/testdata/yaml-list.conf create mode 100644 calamares/src/libcalamares/utils/CommandList.cpp create mode 100644 calamares/src/libcalamares/utils/CommandList.h create mode 100644 calamares/src/libcalamares/utils/Dirs.cpp create mode 100644 calamares/src/libcalamares/utils/Dirs.h create mode 100644 calamares/src/libcalamares/utils/Entropy.cpp create mode 100644 calamares/src/libcalamares/utils/Entropy.h create mode 100644 calamares/src/libcalamares/utils/Logger.cpp create mode 100644 calamares/src/libcalamares/utils/Logger.h create mode 100644 calamares/src/libcalamares/utils/NamedEnum.h create mode 100644 calamares/src/libcalamares/utils/NamedSuffix.h create mode 100644 calamares/src/libcalamares/utils/Permissions.cpp create mode 100644 calamares/src/libcalamares/utils/Permissions.h create mode 100644 calamares/src/libcalamares/utils/PluginFactory.cpp create mode 100644 calamares/src/libcalamares/utils/PluginFactory.h create mode 100644 calamares/src/libcalamares/utils/RAII.h create mode 100644 calamares/src/libcalamares/utils/Retranslator.cpp create mode 100644 calamares/src/libcalamares/utils/Retranslator.h create mode 100644 calamares/src/libcalamares/utils/Runner.cpp create mode 100644 calamares/src/libcalamares/utils/Runner.h create mode 100644 calamares/src/libcalamares/utils/String.cpp create mode 100644 calamares/src/libcalamares/utils/String.h create mode 100644 calamares/src/libcalamares/utils/StringExpander.cpp create mode 100644 calamares/src/libcalamares/utils/StringExpander.h create mode 100644 calamares/src/libcalamares/utils/System.cpp create mode 100644 calamares/src/libcalamares/utils/System.h create mode 100644 calamares/src/libcalamares/utils/TestPaths.cpp create mode 100644 calamares/src/libcalamares/utils/Tests.cpp create mode 100644 calamares/src/libcalamares/utils/Traits.h create mode 100644 calamares/src/libcalamares/utils/UMask.cpp create mode 100644 calamares/src/libcalamares/utils/UMask.h create mode 100644 calamares/src/libcalamares/utils/Units.h create mode 100644 calamares/src/libcalamares/utils/Variant.cpp create mode 100644 calamares/src/libcalamares/utils/Variant.h create mode 100644 calamares/src/libcalamares/utils/Yaml.cpp create mode 100644 calamares/src/libcalamares/utils/Yaml.h create mode 100644 calamares/src/libcalamares/utils/moc-warnings.h create mode 100644 calamares/src/libcalamaresui/Branding.cpp create mode 100644 calamares/src/libcalamaresui/Branding.h create mode 100644 calamares/src/libcalamaresui/CMakeLists.txt create mode 100644 calamares/src/libcalamaresui/ViewManager.cpp create mode 100644 calamares/src/libcalamaresui/ViewManager.h create mode 100644 calamares/src/libcalamaresui/libcalamaresui.qrc create mode 100644 calamares/src/libcalamaresui/modulesystem/CppJobModule.cpp create mode 100644 calamares/src/libcalamaresui/modulesystem/CppJobModule.h create mode 100644 calamares/src/libcalamaresui/modulesystem/ModuleFactory.cpp create mode 100644 calamares/src/libcalamaresui/modulesystem/ModuleFactory.h create mode 100644 calamares/src/libcalamaresui/modulesystem/ModuleManager.cpp create mode 100644 calamares/src/libcalamaresui/modulesystem/ModuleManager.h create mode 100644 calamares/src/libcalamaresui/modulesystem/ProcessJobModule.cpp create mode 100644 calamares/src/libcalamaresui/modulesystem/ProcessJobModule.h create mode 100644 calamares/src/libcalamaresui/modulesystem/PythonJobModule.cpp create mode 100644 calamares/src/libcalamaresui/modulesystem/PythonJobModule.h create mode 100644 calamares/src/libcalamaresui/modulesystem/ViewModule.cpp create mode 100644 calamares/src/libcalamaresui/modulesystem/ViewModule.h create mode 100644 calamares/src/libcalamaresui/utils/Gui.cpp create mode 100644 calamares/src/libcalamaresui/utils/Gui.h create mode 100644 calamares/src/libcalamaresui/utils/ImageRegistry.cpp create mode 100644 calamares/src/libcalamaresui/utils/ImageRegistry.h create mode 100644 calamares/src/libcalamaresui/utils/Paste.cpp create mode 100644 calamares/src/libcalamaresui/utils/Paste.h create mode 100644 calamares/src/libcalamaresui/utils/Qml.cpp create mode 100644 calamares/src/libcalamaresui/utils/Qml.h create mode 100644 calamares/src/libcalamaresui/utils/QtCompat.h create mode 100644 calamares/src/libcalamaresui/utils/TestPaste.cpp create mode 100644 calamares/src/libcalamaresui/viewpages/BlankViewStep.cpp create mode 100644 calamares/src/libcalamaresui/viewpages/BlankViewStep.h create mode 100644 calamares/src/libcalamaresui/viewpages/ExecutionViewStep.cpp create mode 100644 calamares/src/libcalamaresui/viewpages/ExecutionViewStep.h create mode 100644 calamares/src/libcalamaresui/viewpages/QmlViewStep.cpp create mode 100644 calamares/src/libcalamaresui/viewpages/QmlViewStep.h create mode 100644 calamares/src/libcalamaresui/viewpages/Slideshow.cpp create mode 100644 calamares/src/libcalamaresui/viewpages/Slideshow.h create mode 100644 calamares/src/libcalamaresui/viewpages/ViewStep.cpp create mode 100644 calamares/src/libcalamaresui/viewpages/ViewStep.h create mode 100644 calamares/src/libcalamaresui/widgets/ClickableLabel.cpp create mode 100644 calamares/src/libcalamaresui/widgets/ClickableLabel.h create mode 100644 calamares/src/libcalamaresui/widgets/ErrorDialog.cpp create mode 100644 calamares/src/libcalamaresui/widgets/ErrorDialog.h create mode 100644 calamares/src/libcalamaresui/widgets/ErrorDialog.ui create mode 100644 calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.cpp create mode 100644 calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.h create mode 100644 calamares/src/libcalamaresui/widgets/LogWidget.cpp create mode 100644 calamares/src/libcalamaresui/widgets/LogWidget.h create mode 100644 calamares/src/libcalamaresui/widgets/PrettyRadioButton.cpp create mode 100644 calamares/src/libcalamaresui/widgets/PrettyRadioButton.h create mode 100644 calamares/src/libcalamaresui/widgets/TranslationFix.cpp create mode 100644 calamares/src/libcalamaresui/widgets/TranslationFix.h create mode 100644 calamares/src/libcalamaresui/widgets/WaitingWidget.cpp create mode 100644 calamares/src/libcalamaresui/widgets/WaitingWidget.h create mode 100644 calamares/src/libcalamaresui/widgets/waitingspinnerwidget.cpp create mode 100644 calamares/src/libcalamaresui/widgets/waitingspinnerwidget.h create mode 100644 calamares/src/modules/CMakeLists.txt create mode 100644 calamares/src/modules/README.md create mode 100644 calamares/src/modules/bootloader/bootloader.conf create mode 100644 calamares/src/modules/bootloader/bootloader.schema.yaml create mode 100644 calamares/src/modules/bootloader/main.py create mode 100644 calamares/src/modules/bootloader/module.desc create mode 100644 calamares/src/modules/bootloader/test.yaml create mode 100644 calamares/src/modules/bootloader/tests/CMakeTests.txt create mode 100644 calamares/src/modules/bootloader/tests/test-bootloader-efiname.py create mode 100644 calamares/src/modules/contextualprocess/Binding.h create mode 100644 calamares/src/modules/contextualprocess/CMakeLists.txt create mode 100644 calamares/src/modules/contextualprocess/ContextualProcessJob.cpp create mode 100644 calamares/src/modules/contextualprocess/ContextualProcessJob.h create mode 100644 calamares/src/modules/contextualprocess/Tests.cpp create mode 100644 calamares/src/modules/contextualprocess/Tests.h create mode 100644 calamares/src/modules/contextualprocess/contextualprocess.conf create mode 100644 calamares/src/modules/displaymanager/displaymanager.conf create mode 100644 calamares/src/modules/displaymanager/displaymanager.schema.yaml create mode 100644 calamares/src/modules/displaymanager/main.py create mode 100644 calamares/src/modules/displaymanager/module.desc create mode 100644 calamares/src/modules/displaymanager/tests/1.global create mode 100644 calamares/src/modules/displaymanager/tests/CMakeTests.txt create mode 100644 calamares/src/modules/displaymanager/tests/test-dm-gdm.py create mode 100644 calamares/src/modules/displaymanager/tests/test-dm-greetd.py create mode 100644 calamares/src/modules/displaymanager/tests/test-dm-sddm.py create mode 100644 calamares/src/modules/dracut/dracut.conf create mode 100644 calamares/src/modules/dracut/dracut.schema.yaml create mode 100644 calamares/src/modules/dracut/main.py create mode 100644 calamares/src/modules/dracut/module.desc create mode 100644 calamares/src/modules/dracutlukscfg/CMakeLists.txt create mode 100644 calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.cpp create mode 100644 calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.h create mode 100644 calamares/src/modules/dummycpp/CMakeLists.txt create mode 100644 calamares/src/modules/dummycpp/DummyCppJob.cpp create mode 100644 calamares/src/modules/dummycpp/DummyCppJob.h create mode 100644 calamares/src/modules/dummycpp/dummycpp.conf create mode 100644 calamares/src/modules/dummycpp/module.desc create mode 100644 calamares/src/modules/dummyprocess/module.desc create mode 100644 calamares/src/modules/dummypython/dummypython.conf create mode 100644 calamares/src/modules/dummypython/main.py create mode 100644 calamares/src/modules/dummypython/module.desc create mode 100644 calamares/src/modules/dummypython/tests/1.global create mode 100644 calamares/src/modules/finished/CMakeLists.txt create mode 100644 calamares/src/modules/finished/Config.cpp create mode 100644 calamares/src/modules/finished/Config.h create mode 100644 calamares/src/modules/finished/FinishedPage.cpp create mode 100644 calamares/src/modules/finished/FinishedPage.h create mode 100644 calamares/src/modules/finished/FinishedPage.ui create mode 100644 calamares/src/modules/finished/FinishedViewStep.cpp create mode 100644 calamares/src/modules/finished/FinishedViewStep.h create mode 100644 calamares/src/modules/finished/finished.conf create mode 100644 calamares/src/modules/finished/finished.schema.yaml create mode 100644 calamares/src/modules/finishedq/CMakeLists.txt create mode 100644 calamares/src/modules/finishedq/FinishedQmlViewStep.cpp create mode 100644 calamares/src/modules/finishedq/FinishedQmlViewStep.h create mode 100644 calamares/src/modules/finishedq/finishedq-qt6.qml create mode 100644 calamares/src/modules/finishedq/finishedq-qt6.qrc create mode 100644 calamares/src/modules/finishedq/finishedq.conf create mode 100644 calamares/src/modules/finishedq/finishedq.qml create mode 100644 calamares/src/modules/finishedq/finishedq.qrc create mode 100644 calamares/src/modules/finishedq/finishedq@mobile.qml create mode 100644 calamares/src/modules/finishedq/seedling.svg create mode 100644 calamares/src/modules/finishedq/seedling.svg.license create mode 100644 calamares/src/modules/fsresizer/CMakeLists.txt create mode 100644 calamares/src/modules/fsresizer/ResizeFSJob.cpp create mode 100644 calamares/src/modules/fsresizer/ResizeFSJob.h create mode 100644 calamares/src/modules/fsresizer/Tests.cpp create mode 100644 calamares/src/modules/fsresizer/Tests.h create mode 100644 calamares/src/modules/fsresizer/fsresizer.conf create mode 100644 calamares/src/modules/fstab/fstab.conf create mode 100644 calamares/src/modules/fstab/fstab.schema.yaml create mode 100755 calamares/src/modules/fstab/main.py create mode 100644 calamares/src/modules/fstab/module.desc create mode 100644 calamares/src/modules/fstab/test.yaml create mode 100644 calamares/src/modules/fstab/test2.yaml create mode 100644 calamares/src/modules/grubcfg/grubcfg.conf create mode 100644 calamares/src/modules/grubcfg/grubcfg.schema.yaml create mode 100644 calamares/src/modules/grubcfg/main.py create mode 100644 calamares/src/modules/grubcfg/module.desc create mode 100644 calamares/src/modules/grubcfg/tests/1.global create mode 100644 calamares/src/modules/grubcfg/tests/2.global create mode 100644 calamares/src/modules/grubcfg/tests/2.job create mode 100644 calamares/src/modules/grubcfg/tests/3.global create mode 100644 calamares/src/modules/grubcfg/tests/3.job create mode 100644 calamares/src/modules/grubcfg/tests/4.global create mode 100644 calamares/src/modules/grubcfg/tests/4.job create mode 100644 calamares/src/modules/grubcfg/tests/CMakeTests.txt create mode 100644 calamares/src/modules/hostinfo/CMakeLists.txt create mode 100644 calamares/src/modules/hostinfo/HostInfoJob.cpp create mode 100644 calamares/src/modules/hostinfo/HostInfoJob.h create mode 100644 calamares/src/modules/hostinfo/Tests.cpp create mode 100644 calamares/src/modules/hwclock/main.py create mode 100644 calamares/src/modules/hwclock/module.desc create mode 100644 calamares/src/modules/initcpio/CMakeLists.txt create mode 100644 calamares/src/modules/initcpio/InitcpioJob.cpp create mode 100644 calamares/src/modules/initcpio/InitcpioJob.h create mode 100644 calamares/src/modules/initcpio/Tests.cpp create mode 100644 calamares/src/modules/initcpio/Tests.h create mode 100644 calamares/src/modules/initcpio/initcpio.conf create mode 100644 calamares/src/modules/initcpio/initcpio.schema.yaml create mode 100644 calamares/src/modules/initcpiocfg/initcpiocfg.conf create mode 100644 calamares/src/modules/initcpiocfg/initcpiocfg.schema.yaml create mode 100644 calamares/src/modules/initcpiocfg/main.py create mode 100644 calamares/src/modules/initcpiocfg/module.desc create mode 100644 calamares/src/modules/initcpiocfg/test.yaml create mode 100644 calamares/src/modules/initramfs/CMakeLists.txt create mode 100644 calamares/src/modules/initramfs/InitramfsJob.cpp create mode 100644 calamares/src/modules/initramfs/InitramfsJob.h create mode 100644 calamares/src/modules/initramfs/Tests.cpp create mode 100644 calamares/src/modules/initramfs/Tests.h create mode 100644 calamares/src/modules/initramfs/initramfs.conf create mode 100755 calamares/src/modules/initramfscfg/encrypt_hook create mode 100755 calamares/src/modules/initramfscfg/encrypt_hook_nokey create mode 100644 calamares/src/modules/initramfscfg/main.py create mode 100644 calamares/src/modules/initramfscfg/module.desc create mode 100644 calamares/src/modules/interactiveterminal/CMakeLists.txt create mode 100644 calamares/src/modules/interactiveterminal/InteractiveTerminalPage.cpp create mode 100644 calamares/src/modules/interactiveterminal/InteractiveTerminalPage.h create mode 100644 calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.cpp create mode 100644 calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.h create mode 100644 calamares/src/modules/interactiveterminal/interactiveterminal.conf create mode 100644 calamares/src/modules/keyboard/AdditionalLayoutInfo.h create mode 100644 calamares/src/modules/keyboard/CMakeLists.txt create mode 100644 calamares/src/modules/keyboard/Config.cpp create mode 100644 calamares/src/modules/keyboard/Config.h create mode 100644 calamares/src/modules/keyboard/KeyboardData_p.cxxtr create mode 100644 calamares/src/modules/keyboard/KeyboardLayoutModel.cpp create mode 100644 calamares/src/modules/keyboard/KeyboardLayoutModel.h create mode 100644 calamares/src/modules/keyboard/KeyboardPage.cpp create mode 100644 calamares/src/modules/keyboard/KeyboardPage.h create mode 100644 calamares/src/modules/keyboard/KeyboardPage.ui create mode 100644 calamares/src/modules/keyboard/KeyboardViewStep.cpp create mode 100644 calamares/src/modules/keyboard/KeyboardViewStep.h create mode 100644 calamares/src/modules/keyboard/SetKeyboardLayoutJob.cpp create mode 100644 calamares/src/modules/keyboard/SetKeyboardLayoutJob.h create mode 100644 calamares/src/modules/keyboard/Tests.cpp create mode 100644 calamares/src/modules/keyboard/images/restore.png create mode 100644 calamares/src/modules/keyboard/images/restore.png.license create mode 100644 calamares/src/modules/keyboard/kbd-model-map create mode 100644 calamares/src/modules/keyboard/keyboard.conf create mode 100644 calamares/src/modules/keyboard/keyboard.qrc create mode 100644 calamares/src/modules/keyboard/keyboard.schema.yaml create mode 100644 calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.cpp create mode 100644 calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.h create mode 100644 calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.cpp create mode 100644 calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.h create mode 100644 calamares/src/modules/keyboard/layout-extractor.py create mode 100644 calamares/src/modules/keyboard/non-ascii-layouts create mode 100644 calamares/src/modules/keyboardq/CMakeLists.txt create mode 100644 calamares/src/modules/keyboardq/KeyboardQmlViewStep.cpp create mode 100644 calamares/src/modules/keyboardq/KeyboardQmlViewStep.h create mode 100644 calamares/src/modules/keyboardq/data/Key-qt6.qml create mode 100644 calamares/src/modules/keyboardq/data/Key.qml create mode 100644 calamares/src/modules/keyboardq/data/Keyboard-qt6.qml create mode 100644 calamares/src/modules/keyboardq/data/Keyboard.qml create mode 100644 calamares/src/modules/keyboardq/data/afgani-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/afgani.xml create mode 100644 calamares/src/modules/keyboardq/data/ar-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/ar.xml create mode 100755 calamares/src/modules/keyboardq/data/backspace.svg create mode 100644 calamares/src/modules/keyboardq/data/backspace.svg.license create mode 100755 calamares/src/modules/keyboardq/data/button_bkg_center.png create mode 100644 calamares/src/modules/keyboardq/data/button_bkg_center.png.license create mode 100755 calamares/src/modules/keyboardq/data/button_bkg_left.png create mode 100644 calamares/src/modules/keyboardq/data/button_bkg_left.png.license create mode 100755 calamares/src/modules/keyboardq/data/button_bkg_right.png create mode 100644 calamares/src/modules/keyboardq/data/button_bkg_right.png.license create mode 100644 calamares/src/modules/keyboardq/data/de-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/de.xml create mode 100644 calamares/src/modules/keyboardq/data/empty-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/empty.xml create mode 100644 calamares/src/modules/keyboardq/data/en-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/en.xml create mode 100755 calamares/src/modules/keyboardq/data/enter.svg create mode 100644 calamares/src/modules/keyboardq/data/enter.svg.license create mode 100644 calamares/src/modules/keyboardq/data/es-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/es.xml create mode 100644 calamares/src/modules/keyboardq/data/fr-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/fr.xml create mode 100644 calamares/src/modules/keyboardq/data/generic-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/generic.xml create mode 100644 calamares/src/modules/keyboardq/data/generic_qz-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/generic_qz.xml create mode 100644 calamares/src/modules/keyboardq/data/pan-end-symbolic.svg create mode 100644 calamares/src/modules/keyboardq/data/pan-end-symbolic.svg.license create mode 100644 calamares/src/modules/keyboardq/data/pt-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/pt.xml create mode 100644 calamares/src/modules/keyboardq/data/ru-qt6.xml create mode 100644 calamares/src/modules/keyboardq/data/ru.xml create mode 100644 calamares/src/modules/keyboardq/data/scan.xml create mode 100755 calamares/src/modules/keyboardq/data/shift.svg create mode 100644 calamares/src/modules/keyboardq/data/shift.svg.license create mode 100644 calamares/src/modules/keyboardq/keyboardq-qt6.qml create mode 100644 calamares/src/modules/keyboardq/keyboardq-qt6.qrc create mode 100644 calamares/src/modules/keyboardq/keyboardq.conf create mode 100644 calamares/src/modules/keyboardq/keyboardq.qml create mode 100644 calamares/src/modules/keyboardq/keyboardq.qrc create mode 100644 calamares/src/modules/license/CMakeLists.txt create mode 100644 calamares/src/modules/license/LicensePage.cpp create mode 100644 calamares/src/modules/license/LicensePage.h create mode 100644 calamares/src/modules/license/LicensePage.ui create mode 100644 calamares/src/modules/license/LicenseViewStep.cpp create mode 100644 calamares/src/modules/license/LicenseViewStep.h create mode 100644 calamares/src/modules/license/LicenseWidget.cpp create mode 100644 calamares/src/modules/license/LicenseWidget.h create mode 100644 calamares/src/modules/license/README.md create mode 100644 calamares/src/modules/license/license.conf create mode 100644 calamares/src/modules/license/license.schema.yaml create mode 100644 calamares/src/modules/locale/CMakeLists.txt create mode 100644 calamares/src/modules/locale/Config.cpp create mode 100644 calamares/src/modules/locale/Config.h create mode 100644 calamares/src/modules/locale/LCLocaleDialog.cpp create mode 100644 calamares/src/modules/locale/LCLocaleDialog.h create mode 100644 calamares/src/modules/locale/LocaleConfiguration.cpp create mode 100644 calamares/src/modules/locale/LocaleConfiguration.h create mode 100644 calamares/src/modules/locale/LocaleNames.cpp create mode 100644 calamares/src/modules/locale/LocaleNames.h create mode 100644 calamares/src/modules/locale/LocalePage.cpp create mode 100644 calamares/src/modules/locale/LocalePage.h create mode 100644 calamares/src/modules/locale/LocaleViewStep.cpp create mode 100644 calamares/src/modules/locale/LocaleViewStep.h create mode 100644 calamares/src/modules/locale/SetTimezoneJob.cpp create mode 100644 calamares/src/modules/locale/SetTimezoneJob.h create mode 100644 calamares/src/modules/locale/Tests.cpp create mode 100644 calamares/src/modules/locale/images/bg.png create mode 100644 calamares/src/modules/locale/images/bg.png.license create mode 100644 calamares/src/modules/locale/images/pin.png create mode 100644 calamares/src/modules/locale/images/pin.png.license create mode 100644 calamares/src/modules/locale/images/timezone_-1.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-10.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-11.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-2.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-3.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-3.5.png create mode 100644 calamares/src/modules/locale/images/timezone_-4.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-4.5.png create mode 100644 calamares/src/modules/locale/images/timezone_-5.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-5.5.png create mode 100644 calamares/src/modules/locale/images/timezone_-6.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-7.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-8.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-9.0.png create mode 100644 calamares/src/modules/locale/images/timezone_-9.5.png create mode 100644 calamares/src/modules/locale/images/timezone_0.0.png create mode 100644 calamares/src/modules/locale/images/timezone_1.0.png create mode 100644 calamares/src/modules/locale/images/timezone_10.0.png create mode 100644 calamares/src/modules/locale/images/timezone_10.5.png create mode 100644 calamares/src/modules/locale/images/timezone_11.0.png create mode 100644 calamares/src/modules/locale/images/timezone_12.0.png create mode 100644 calamares/src/modules/locale/images/timezone_12.75.png create mode 100644 calamares/src/modules/locale/images/timezone_13.0.png create mode 100644 calamares/src/modules/locale/images/timezone_2.0.png create mode 100644 calamares/src/modules/locale/images/timezone_3.0.png create mode 100644 calamares/src/modules/locale/images/timezone_3.5.png create mode 100644 calamares/src/modules/locale/images/timezone_4.0.png create mode 100644 calamares/src/modules/locale/images/timezone_4.5.png create mode 100644 calamares/src/modules/locale/images/timezone_5.0.png create mode 100644 calamares/src/modules/locale/images/timezone_5.5.png create mode 100644 calamares/src/modules/locale/images/timezone_5.75.png create mode 100644 calamares/src/modules/locale/images/timezone_6.0.png create mode 100644 calamares/src/modules/locale/images/timezone_6.5.png create mode 100644 calamares/src/modules/locale/images/timezone_7.0.png create mode 100644 calamares/src/modules/locale/images/timezone_8.0.png create mode 100644 calamares/src/modules/locale/images/timezone_9.0.png create mode 100644 calamares/src/modules/locale/images/timezone_9.5.png create mode 100644 calamares/src/modules/locale/locale.conf create mode 100644 calamares/src/modules/locale/locale.qrc create mode 100644 calamares/src/modules/locale/locale.schema.yaml create mode 100644 calamares/src/modules/locale/tests/locale-data-freebsd create mode 100644 calamares/src/modules/locale/tests/locale-data-neon create mode 100644 calamares/src/modules/locale/timezonewidget/TimeZoneImage.cpp create mode 100644 calamares/src/modules/locale/timezonewidget/TimeZoneImage.h create mode 100644 calamares/src/modules/locale/timezonewidget/timezonewidget.cpp create mode 100644 calamares/src/modules/locale/timezonewidget/timezonewidget.h create mode 100644 calamares/src/modules/localecfg/main.py create mode 100644 calamares/src/modules/localecfg/module.desc create mode 100644 calamares/src/modules/localeq/CMakeLists.txt create mode 100644 calamares/src/modules/localeq/LocaleQmlViewStep.cpp create mode 100644 calamares/src/modules/localeq/LocaleQmlViewStep.h create mode 100644 calamares/src/modules/localeq/Map-qt6.qml create mode 100644 calamares/src/modules/localeq/Map.qml create mode 100644 calamares/src/modules/localeq/Offline-qt6.qml create mode 100644 calamares/src/modules/localeq/Offline.qml create mode 100755 calamares/src/modules/localeq/img/locale.svg create mode 100644 calamares/src/modules/localeq/img/locale.svg.license create mode 100644 calamares/src/modules/localeq/img/minus.png create mode 100644 calamares/src/modules/localeq/img/minus.png.license create mode 100644 calamares/src/modules/localeq/img/pin.svg create mode 100644 calamares/src/modules/localeq/img/pin.svg.license create mode 100644 calamares/src/modules/localeq/img/plus.png create mode 100644 calamares/src/modules/localeq/img/plus.png.license create mode 100644 calamares/src/modules/localeq/localeq-qt6.qml create mode 100644 calamares/src/modules/localeq/localeq-qt6.qrc create mode 100644 calamares/src/modules/localeq/localeq.conf create mode 100644 calamares/src/modules/localeq/localeq.qml create mode 100644 calamares/src/modules/localeq/localeq.qrc create mode 100644 calamares/src/modules/luksbootkeyfile/CMakeLists.txt create mode 100644 calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.cpp create mode 100644 calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.h create mode 100644 calamares/src/modules/luksbootkeyfile/Tests.cpp create mode 100644 calamares/src/modules/luksbootkeyfile/luksbootkeyfile.conf create mode 100644 calamares/src/modules/luksbootkeyfile/luksbootkeyfile.schema.yaml create mode 100644 calamares/src/modules/luksopenswaphookcfg/CMakeLists.txt create mode 100644 calamares/src/modules/luksopenswaphookcfg/LOSHInfo.h create mode 100644 calamares/src/modules/luksopenswaphookcfg/LOSHJob.cpp create mode 100644 calamares/src/modules/luksopenswaphookcfg/LOSHJob.h create mode 100644 calamares/src/modules/luksopenswaphookcfg/Tests.cpp create mode 100644 calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.conf create mode 100644 calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.schema.yaml create mode 100644 calamares/src/modules/machineid/CMakeLists.txt create mode 100644 calamares/src/modules/machineid/MachineIdJob.cpp create mode 100644 calamares/src/modules/machineid/MachineIdJob.h create mode 100644 calamares/src/modules/machineid/Tests.cpp create mode 100644 calamares/src/modules/machineid/Workers.cpp create mode 100644 calamares/src/modules/machineid/Workers.h create mode 100644 calamares/src/modules/machineid/machineid.conf create mode 100644 calamares/src/modules/machineid/machineid.schema.yaml create mode 100644 calamares/src/modules/mkinitfs/main.py create mode 100644 calamares/src/modules/mkinitfs/module.desc create mode 100644 calamares/src/modules/mount/main.py create mode 100644 calamares/src/modules/mount/module.desc create mode 100644 calamares/src/modules/mount/mount.conf create mode 100644 calamares/src/modules/mount/mount.schema.yaml create mode 100644 calamares/src/modules/mount/tests/1.global create mode 100644 calamares/src/modules/mount/tests/1.job create mode 100644 calamares/src/modules/mount/tests/2.global create mode 100644 calamares/src/modules/mount/tests/2.job create mode 100644 calamares/src/modules/mount/tests/3.global create mode 100644 calamares/src/modules/mount/tests/3.job create mode 100644 calamares/src/modules/mount/tests/4.global create mode 100644 calamares/src/modules/mount/tests/4.job create mode 100644 calamares/src/modules/netinstall/CMakeLists.txt create mode 100644 calamares/src/modules/netinstall/Config.cpp create mode 100644 calamares/src/modules/netinstall/Config.h create mode 100644 calamares/src/modules/netinstall/LoaderQueue.cpp create mode 100644 calamares/src/modules/netinstall/LoaderQueue.h create mode 100644 calamares/src/modules/netinstall/NetInstallPage.cpp create mode 100644 calamares/src/modules/netinstall/NetInstallPage.h create mode 100644 calamares/src/modules/netinstall/NetInstallViewStep.cpp create mode 100644 calamares/src/modules/netinstall/NetInstallViewStep.h create mode 100644 calamares/src/modules/netinstall/PackageModel.cpp create mode 100644 calamares/src/modules/netinstall/PackageModel.h create mode 100644 calamares/src/modules/netinstall/PackageTreeItem.cpp create mode 100644 calamares/src/modules/netinstall/PackageTreeItem.h create mode 100644 calamares/src/modules/netinstall/Tests.cpp create mode 100644 calamares/src/modules/netinstall/groupstreeview.cpp create mode 100644 calamares/src/modules/netinstall/groupstreeview.h create mode 100644 calamares/src/modules/netinstall/netinstall.conf create mode 100644 calamares/src/modules/netinstall/netinstall.schema.yaml create mode 100644 calamares/src/modules/netinstall/netinstall.yaml create mode 100644 calamares/src/modules/netinstall/page_netinst.ui create mode 100644 calamares/src/modules/netinstall/tests/1a-single-bad.conf create mode 100644 calamares/src/modules/netinstall/tests/1a-single-empty.conf create mode 100644 calamares/src/modules/netinstall/tests/1a-single-error.conf create mode 100644 calamares/src/modules/netinstall/tests/1b-single-large.conf create mode 100644 calamares/src/modules/netinstall/tests/1b-single-small.conf create mode 100644 calamares/src/modules/netinstall/tests/1c-none.conf create mode 100644 calamares/src/modules/netinstall/tests/1c-unset.conf create mode 100644 calamares/src/modules/netinstall/tests/1d-fallback-bad.conf create mode 100644 calamares/src/modules/netinstall/tests/1d-fallback-large.conf create mode 100644 calamares/src/modules/netinstall/tests/1d-fallback-mixed.conf create mode 100644 calamares/src/modules/netinstall/tests/1d-fallback-small.conf create mode 100644 calamares/src/modules/netinstall/tests/data-empty.yaml create mode 100644 calamares/src/modules/netinstall/tests/data-error.yaml create mode 100644 calamares/src/modules/netinstall/tests/data-large.yaml create mode 100644 calamares/src/modules/netinstall/tests/data-small.yaml create mode 100644 calamares/src/modules/networkcfg/main.py create mode 100644 calamares/src/modules/networkcfg/module.desc create mode 100644 calamares/src/modules/notesqml/CMakeLists.txt create mode 100644 calamares/src/modules/notesqml/NotesQmlViewStep.cpp create mode 100644 calamares/src/modules/notesqml/NotesQmlViewStep.h create mode 100644 calamares/src/modules/notesqml/examples/notesqml.qml.example create mode 100644 calamares/src/modules/notesqml/notesqml.conf create mode 100644 calamares/src/modules/notesqml/notesqml.qml create mode 100644 calamares/src/modules/notesqml/notesqml.qrc create mode 100644 calamares/src/modules/oemid/CMakeLists.txt create mode 100644 calamares/src/modules/oemid/IDJob.cpp create mode 100644 calamares/src/modules/oemid/IDJob.h create mode 100644 calamares/src/modules/oemid/OEMPage.ui create mode 100644 calamares/src/modules/oemid/OEMViewStep.cpp create mode 100644 calamares/src/modules/oemid/OEMViewStep.h create mode 100644 calamares/src/modules/oemid/oemid.conf create mode 100644 calamares/src/modules/openrcdmcryptcfg/main.py create mode 100644 calamares/src/modules/openrcdmcryptcfg/module.desc create mode 100644 calamares/src/modules/openrcdmcryptcfg/openrcdmcryptcfg.conf create mode 100644 calamares/src/modules/packagechooser/CMakeLists.txt create mode 100644 calamares/src/modules/packagechooser/Config.cpp create mode 100644 calamares/src/modules/packagechooser/Config.h create mode 100644 calamares/src/modules/packagechooser/ItemAppData.cpp create mode 100644 calamares/src/modules/packagechooser/ItemAppData.h create mode 100644 calamares/src/modules/packagechooser/ItemAppStream.cpp create mode 100644 calamares/src/modules/packagechooser/ItemAppStream.h create mode 100644 calamares/src/modules/packagechooser/PackageChooserPage.cpp create mode 100644 calamares/src/modules/packagechooser/PackageChooserPage.h create mode 100644 calamares/src/modules/packagechooser/PackageChooserViewStep.cpp create mode 100644 calamares/src/modules/packagechooser/PackageChooserViewStep.h create mode 100644 calamares/src/modules/packagechooser/PackageModel.cpp create mode 100644 calamares/src/modules/packagechooser/PackageModel.h create mode 100644 calamares/src/modules/packagechooser/Tests.cpp create mode 100644 calamares/src/modules/packagechooser/Tests.h create mode 100644 calamares/src/modules/packagechooser/images/calamares.png create mode 100644 calamares/src/modules/packagechooser/images/calamares.png.license create mode 100644 calamares/src/modules/packagechooser/images/no-selection.png create mode 100644 calamares/src/modules/packagechooser/images/no-selection.png.license create mode 100644 calamares/src/modules/packagechooser/packagechooser.conf create mode 100644 calamares/src/modules/packagechooser/packagechooser.qrc create mode 100644 calamares/src/modules/packagechooser/page_package.ui create mode 100644 calamares/src/modules/packagechooserq/CMakeLists.txt create mode 100644 calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.cpp create mode 100644 calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.h create mode 100644 calamares/src/modules/packagechooserq/images/libreoffice.jpg create mode 100644 calamares/src/modules/packagechooserq/images/libreoffice.jpg.license create mode 100644 calamares/src/modules/packagechooserq/images/no-selection.png create mode 100644 calamares/src/modules/packagechooserq/images/no-selection.png.license create mode 100644 calamares/src/modules/packagechooserq/images/plasma.png create mode 100644 calamares/src/modules/packagechooserq/images/plasma.png.license create mode 100644 calamares/src/modules/packagechooserq/packagechooserq-qt6.qml create mode 100644 calamares/src/modules/packagechooserq/packagechooserq-qt6.qrc create mode 100644 calamares/src/modules/packagechooserq/packagechooserq.conf create mode 100644 calamares/src/modules/packagechooserq/packagechooserq.qml create mode 100644 calamares/src/modules/packagechooserq/packagechooserq.qrc create mode 100644 calamares/src/modules/packages/main.py create mode 100644 calamares/src/modules/packages/module.desc create mode 100644 calamares/src/modules/packages/packages.conf create mode 100644 calamares/src/modules/packages/packages.schema.yaml create mode 100644 calamares/src/modules/packages/tests/1.global create mode 100644 calamares/src/modules/packages/tests/2.job create mode 100644 calamares/src/modules/packages/tests/CMakeTests.txt create mode 100644 calamares/src/modules/packages/tests/pm-pacman-1.yaml create mode 100644 calamares/src/modules/packages/tests/pm-pacman-2.yaml create mode 100644 calamares/src/modules/packages/tests/test-pm-pacman.py create mode 100644 calamares/src/modules/partition/CMakeLists.txt create mode 100644 calamares/src/modules/partition/Config.cpp create mode 100644 calamares/src/modules/partition/Config.h create mode 100644 calamares/src/modules/partition/PartitionViewStep.cpp create mode 100644 calamares/src/modules/partition/PartitionViewStep.h create mode 100644 calamares/src/modules/partition/README.md create mode 100644 calamares/src/modules/partition/core/BootLoaderModel.cpp create mode 100644 calamares/src/modules/partition/core/BootLoaderModel.h create mode 100644 calamares/src/modules/partition/core/ColorUtils.cpp create mode 100644 calamares/src/modules/partition/core/ColorUtils.h create mode 100644 calamares/src/modules/partition/core/DeviceList.cpp create mode 100644 calamares/src/modules/partition/core/DeviceList.h create mode 100644 calamares/src/modules/partition/core/DeviceModel.cpp create mode 100644 calamares/src/modules/partition/core/DeviceModel.h create mode 100644 calamares/src/modules/partition/core/DirFSRestrictLayout.cpp create mode 100644 calamares/src/modules/partition/core/DirFSRestrictLayout.h create mode 100644 calamares/src/modules/partition/core/KPMHelpers.cpp create mode 100644 calamares/src/modules/partition/core/KPMHelpers.h create mode 100644 calamares/src/modules/partition/core/OsproberEntry.cpp create mode 100644 calamares/src/modules/partition/core/OsproberEntry.h create mode 100644 calamares/src/modules/partition/core/PartUtils.cpp create mode 100644 calamares/src/modules/partition/core/PartUtils.h create mode 100644 calamares/src/modules/partition/core/PartitionActions.cpp create mode 100644 calamares/src/modules/partition/core/PartitionActions.h create mode 100644 calamares/src/modules/partition/core/PartitionCoreModule.cpp create mode 100644 calamares/src/modules/partition/core/PartitionCoreModule.h create mode 100644 calamares/src/modules/partition/core/PartitionInfo.cpp create mode 100644 calamares/src/modules/partition/core/PartitionInfo.h create mode 100644 calamares/src/modules/partition/core/PartitionLayout.cpp create mode 100644 calamares/src/modules/partition/core/PartitionLayout.h create mode 100644 calamares/src/modules/partition/core/PartitionModel.cpp create mode 100644 calamares/src/modules/partition/core/PartitionModel.h create mode 100644 calamares/src/modules/partition/core/SizeUtils.h create mode 100644 calamares/src/modules/partition/gui/BootInfoWidget.cpp create mode 100644 calamares/src/modules/partition/gui/BootInfoWidget.h create mode 100644 calamares/src/modules/partition/gui/ChoicePage.cpp create mode 100644 calamares/src/modules/partition/gui/ChoicePage.h create mode 100644 calamares/src/modules/partition/gui/ChoicePage.ui create mode 100644 calamares/src/modules/partition/gui/CreatePartitionDialog.cpp create mode 100644 calamares/src/modules/partition/gui/CreatePartitionDialog.h create mode 100644 calamares/src/modules/partition/gui/CreatePartitionDialog.ui create mode 100644 calamares/src/modules/partition/gui/CreatePartitionTableDialog.ui create mode 100644 calamares/src/modules/partition/gui/CreateVolumeGroupDialog.cpp create mode 100644 calamares/src/modules/partition/gui/CreateVolumeGroupDialog.h create mode 100644 calamares/src/modules/partition/gui/DeviceInfoWidget.cpp create mode 100644 calamares/src/modules/partition/gui/DeviceInfoWidget.h create mode 100644 calamares/src/modules/partition/gui/EditExistingPartitionDialog.cpp create mode 100644 calamares/src/modules/partition/gui/EditExistingPartitionDialog.h create mode 100644 calamares/src/modules/partition/gui/EditExistingPartitionDialog.ui create mode 100644 calamares/src/modules/partition/gui/EncryptWidget.cpp create mode 100644 calamares/src/modules/partition/gui/EncryptWidget.h create mode 100644 calamares/src/modules/partition/gui/EncryptWidget.ui create mode 100644 calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.cpp create mode 100644 calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.h create mode 100644 calamares/src/modules/partition/gui/PartitionBarsView.cpp create mode 100644 calamares/src/modules/partition/gui/PartitionBarsView.h create mode 100644 calamares/src/modules/partition/gui/PartitionDialogHelpers.cpp create mode 100644 calamares/src/modules/partition/gui/PartitionDialogHelpers.h create mode 100644 calamares/src/modules/partition/gui/PartitionLabelsView.cpp create mode 100644 calamares/src/modules/partition/gui/PartitionLabelsView.h create mode 100644 calamares/src/modules/partition/gui/PartitionPage.cpp create mode 100644 calamares/src/modules/partition/gui/PartitionPage.h create mode 100644 calamares/src/modules/partition/gui/PartitionPage.ui create mode 100644 calamares/src/modules/partition/gui/PartitionSizeController.cpp create mode 100644 calamares/src/modules/partition/gui/PartitionSizeController.h create mode 100644 calamares/src/modules/partition/gui/PartitionSplitterWidget.cpp create mode 100644 calamares/src/modules/partition/gui/PartitionSplitterWidget.h create mode 100644 calamares/src/modules/partition/gui/PartitionViewSelectionFilter.h create mode 100644 calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.cpp create mode 100644 calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.h create mode 100644 calamares/src/modules/partition/gui/ScanningDialog.cpp create mode 100644 calamares/src/modules/partition/gui/ScanningDialog.h create mode 100644 calamares/src/modules/partition/gui/VolumeGroupBaseDialog.cpp create mode 100644 calamares/src/modules/partition/gui/VolumeGroupBaseDialog.h create mode 100644 calamares/src/modules/partition/gui/VolumeGroupBaseDialog.ui create mode 100644 calamares/src/modules/partition/jobs/AutoMountManagementJob.cpp create mode 100644 calamares/src/modules/partition/jobs/AutoMountManagementJob.h create mode 100644 calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.cpp create mode 100644 calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.h create mode 100644 calamares/src/modules/partition/jobs/ClearMountsJob.cpp create mode 100644 calamares/src/modules/partition/jobs/ClearMountsJob.h create mode 100644 calamares/src/modules/partition/jobs/ClearTempMountsJob.cpp create mode 100644 calamares/src/modules/partition/jobs/ClearTempMountsJob.h create mode 100644 calamares/src/modules/partition/jobs/CreatePartitionJob.cpp create mode 100644 calamares/src/modules/partition/jobs/CreatePartitionJob.h create mode 100644 calamares/src/modules/partition/jobs/CreatePartitionTableJob.cpp create mode 100644 calamares/src/modules/partition/jobs/CreatePartitionTableJob.h create mode 100644 calamares/src/modules/partition/jobs/CreateVolumeGroupJob.cpp create mode 100644 calamares/src/modules/partition/jobs/CreateVolumeGroupJob.h create mode 100644 calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.cpp create mode 100644 calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.h create mode 100644 calamares/src/modules/partition/jobs/DeletePartitionJob.cpp create mode 100644 calamares/src/modules/partition/jobs/DeletePartitionJob.h create mode 100644 calamares/src/modules/partition/jobs/FillGlobalStorageJob.cpp create mode 100644 calamares/src/modules/partition/jobs/FillGlobalStorageJob.h create mode 100644 calamares/src/modules/partition/jobs/FormatPartitionJob.cpp create mode 100644 calamares/src/modules/partition/jobs/FormatPartitionJob.h create mode 100644 calamares/src/modules/partition/jobs/PartitionJob.cpp create mode 100644 calamares/src/modules/partition/jobs/PartitionJob.h create mode 100644 calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.cpp create mode 100644 calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.h create mode 100644 calamares/src/modules/partition/jobs/ResizePartitionJob.cpp create mode 100644 calamares/src/modules/partition/jobs/ResizePartitionJob.h create mode 100644 calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.cpp create mode 100644 calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.h create mode 100644 calamares/src/modules/partition/jobs/SetPartitionFlagsJob.cpp create mode 100644 calamares/src/modules/partition/jobs/SetPartitionFlagsJob.h create mode 100644 calamares/src/modules/partition/partition.conf create mode 100644 calamares/src/modules/partition/partition.schema.yaml create mode 100644 calamares/src/modules/partition/tests/1a-legacy.conf create mode 100644 calamares/src/modules/partition/tests/1b-legacy.conf create mode 100644 calamares/src/modules/partition/tests/2a-legacy.conf create mode 100644 calamares/src/modules/partition/tests/2b-modern.conf create mode 100644 calamares/src/modules/partition/tests/2c-mixed.conf create mode 100644 calamares/src/modules/partition/tests/2d-overlap.conf create mode 100644 calamares/src/modules/partition/tests/3a-min-too-large.conf create mode 100644 calamares/src/modules/partition/tests/AutoMountTests.cpp create mode 100644 calamares/src/modules/partition/tests/CMakeLists.txt create mode 100644 calamares/src/modules/partition/tests/ClearMountsJobTests.cpp create mode 100644 calamares/src/modules/partition/tests/ClearMountsJobTests.h create mode 100644 calamares/src/modules/partition/tests/ConfigTests.cpp create mode 100644 calamares/src/modules/partition/tests/CreateLayoutsTests.cpp create mode 100644 calamares/src/modules/partition/tests/CreateLayoutsTests.h create mode 100644 calamares/src/modules/partition/tests/DevicesTests.cpp create mode 100644 calamares/src/modules/partition/tests/PartitionJobTests.cpp create mode 100644 calamares/src/modules/partition/tests/PartitionJobTests.h create mode 100644 calamares/src/modules/plasmalnf/CMakeLists.txt create mode 100644 calamares/src/modules/plasmalnf/Config.cpp create mode 100644 calamares/src/modules/plasmalnf/Config.h create mode 100644 calamares/src/modules/plasmalnf/PlasmaLnfJob.cpp create mode 100644 calamares/src/modules/plasmalnf/PlasmaLnfJob.h create mode 100644 calamares/src/modules/plasmalnf/PlasmaLnfPage.cpp create mode 100644 calamares/src/modules/plasmalnf/PlasmaLnfPage.h create mode 100644 calamares/src/modules/plasmalnf/PlasmaLnfViewStep.cpp create mode 100644 calamares/src/modules/plasmalnf/PlasmaLnfViewStep.h create mode 100644 calamares/src/modules/plasmalnf/ThemeInfo.cpp create mode 100644 calamares/src/modules/plasmalnf/ThemeInfo.h create mode 100644 calamares/src/modules/plasmalnf/page_plasmalnf.qrc create mode 100644 calamares/src/modules/plasmalnf/page_plasmalnf.ui create mode 100644 calamares/src/modules/plasmalnf/plasmalnf.conf create mode 100644 calamares/src/modules/plasmalnf/view-preview.png create mode 100644 calamares/src/modules/plasmalnf/view-preview.png.license create mode 100644 calamares/src/modules/plasmalnf/view-preview.svg create mode 100644 calamares/src/modules/plasmalnf/view-preview.svg.license create mode 100644 calamares/src/modules/plymouthcfg/main.py create mode 100644 calamares/src/modules/plymouthcfg/module.desc create mode 100644 calamares/src/modules/plymouthcfg/plymouthcfg.conf create mode 100644 calamares/src/modules/plymouthcfg/plymouthcfg.schema.yaml create mode 100644 calamares/src/modules/preservefiles/CMakeLists.txt create mode 100644 calamares/src/modules/preservefiles/Item.cpp create mode 100644 calamares/src/modules/preservefiles/Item.h create mode 100644 calamares/src/modules/preservefiles/PreserveFiles.cpp create mode 100644 calamares/src/modules/preservefiles/PreserveFiles.h create mode 100644 calamares/src/modules/preservefiles/Tests.cpp create mode 100644 calamares/src/modules/preservefiles/preservefiles.conf create mode 100644 calamares/src/modules/preservefiles/preservefiles.schema.yaml create mode 100644 calamares/src/modules/preservefiles/tests/1a-log.conf create mode 100644 calamares/src/modules/preservefiles/tests/1b-config.conf create mode 100644 calamares/src/modules/preservefiles/tests/1c-src.conf create mode 100644 calamares/src/modules/preservefiles/tests/1d-filename.conf create mode 100644 calamares/src/modules/preservefiles/tests/1e-empty.conf create mode 100644 calamares/src/modules/preservefiles/tests/1f-bad.conf create mode 100644 calamares/src/modules/rawfs/main.py create mode 100644 calamares/src/modules/rawfs/module.desc create mode 100644 calamares/src/modules/rawfs/rawfs.conf create mode 100644 calamares/src/modules/rawfs/tests/1.global create mode 100644 calamares/src/modules/rawfs/tests/1.job create mode 100644 calamares/src/modules/rawfs/tests/CMakeTests.txt create mode 100644 calamares/src/modules/removeuser/CMakeLists.txt create mode 100644 calamares/src/modules/removeuser/RemoveUserJob.cpp create mode 100644 calamares/src/modules/removeuser/RemoveUserJob.h create mode 100644 calamares/src/modules/removeuser/removeuser.conf create mode 100644 calamares/src/modules/removeuser/removeuser.schema.yaml create mode 100644 calamares/src/modules/services-openrc/main.py create mode 100644 calamares/src/modules/services-openrc/module.desc create mode 100644 calamares/src/modules/services-openrc/services-openrc.conf create mode 100644 calamares/src/modules/services-systemd/main.py create mode 100644 calamares/src/modules/services-systemd/module.desc create mode 100644 calamares/src/modules/services-systemd/services-systemd.conf create mode 100644 calamares/src/modules/services-systemd/services-systemd.schema.yaml create mode 100644 calamares/src/modules/shellprocess/CMakeLists.txt create mode 100644 calamares/src/modules/shellprocess/ShellProcessJob.cpp create mode 100644 calamares/src/modules/shellprocess/ShellProcessJob.h create mode 100644 calamares/src/modules/shellprocess/Tests.cpp create mode 100644 calamares/src/modules/shellprocess/Tests.h create mode 100644 calamares/src/modules/shellprocess/shellprocess.conf create mode 100644 calamares/src/modules/shellprocess/shellprocess.schema.yaml create mode 100644 calamares/src/modules/summary/CMakeLists.txt create mode 100644 calamares/src/modules/summary/Config.cpp create mode 100644 calamares/src/modules/summary/Config.h create mode 100644 calamares/src/modules/summary/SummaryModel.cpp create mode 100644 calamares/src/modules/summary/SummaryModel.h create mode 100644 calamares/src/modules/summary/SummaryPage.cpp create mode 100644 calamares/src/modules/summary/SummaryPage.h create mode 100644 calamares/src/modules/summary/SummaryViewStep.cpp create mode 100644 calamares/src/modules/summary/SummaryViewStep.h create mode 100644 calamares/src/modules/summaryq/CMakeLists.txt create mode 100644 calamares/src/modules/summaryq/SummaryQmlViewStep.cpp create mode 100644 calamares/src/modules/summaryq/SummaryQmlViewStep.h create mode 100644 calamares/src/modules/summaryq/img/keyboard.svg create mode 100644 calamares/src/modules/summaryq/img/keyboard.svg.license create mode 100644 calamares/src/modules/summaryq/img/lokalize.svg create mode 100644 calamares/src/modules/summaryq/img/lokalize.svg.license create mode 100644 calamares/src/modules/summaryq/summaryq-qt6.qml create mode 100644 calamares/src/modules/summaryq/summaryq-qt6.qrc create mode 100644 calamares/src/modules/summaryq/summaryq.qml create mode 100644 calamares/src/modules/summaryq/summaryq.qrc create mode 100644 calamares/src/modules/tracking/CMakeLists.txt create mode 100644 calamares/src/modules/tracking/Config.cpp create mode 100644 calamares/src/modules/tracking/Config.h create mode 100644 calamares/src/modules/tracking/Tests.cpp create mode 100644 calamares/src/modules/tracking/TrackingJobs.cpp create mode 100644 calamares/src/modules/tracking/TrackingJobs.h create mode 100644 calamares/src/modules/tracking/TrackingPage.cpp create mode 100644 calamares/src/modules/tracking/TrackingPage.h create mode 100644 calamares/src/modules/tracking/TrackingType.h create mode 100644 calamares/src/modules/tracking/TrackingViewStep.cpp create mode 100644 calamares/src/modules/tracking/TrackingViewStep.h create mode 100644 calamares/src/modules/tracking/level-install.svg create mode 100644 calamares/src/modules/tracking/level-install.svg.license create mode 100644 calamares/src/modules/tracking/level-machine.svg create mode 100644 calamares/src/modules/tracking/level-machine.svg.license create mode 100644 calamares/src/modules/tracking/level-none.svg create mode 100644 calamares/src/modules/tracking/level-none.svg.license create mode 100644 calamares/src/modules/tracking/level-user.svg create mode 100644 calamares/src/modules/tracking/level-user.svg.license create mode 100644 calamares/src/modules/tracking/page_trackingstep.qrc create mode 100644 calamares/src/modules/tracking/page_trackingstep.ui create mode 100644 calamares/src/modules/tracking/tracking.conf create mode 100644 calamares/src/modules/umount/CMakeLists.txt create mode 100644 calamares/src/modules/umount/Tests.cpp create mode 100644 calamares/src/modules/umount/UmountJob.cpp create mode 100644 calamares/src/modules/umount/UmountJob.h create mode 100644 calamares/src/modules/umount/umount.conf create mode 100644 calamares/src/modules/umount/umount.schema.yaml create mode 100644 calamares/src/modules/unpackfs/main.py create mode 100644 calamares/src/modules/unpackfs/module.desc create mode 100644 calamares/src/modules/unpackfs/runtests.sh create mode 100644 calamares/src/modules/unpackfs/tests/1.global create mode 100644 calamares/src/modules/unpackfs/tests/2.global create mode 100644 calamares/src/modules/unpackfs/tests/3.global create mode 100644 calamares/src/modules/unpackfs/tests/3.job create mode 100644 calamares/src/modules/unpackfs/tests/4.global create mode 100644 calamares/src/modules/unpackfs/tests/4.job create mode 100644 calamares/src/modules/unpackfs/tests/5.global create mode 100644 calamares/src/modules/unpackfs/tests/5.job create mode 100644 calamares/src/modules/unpackfs/tests/6.global create mode 100644 calamares/src/modules/unpackfs/tests/6.job create mode 100644 calamares/src/modules/unpackfs/tests/7.global create mode 100644 calamares/src/modules/unpackfs/tests/7.job create mode 100644 calamares/src/modules/unpackfs/tests/8.global create mode 100644 calamares/src/modules/unpackfs/tests/8.job create mode 100644 calamares/src/modules/unpackfs/tests/9.global create mode 100644 calamares/src/modules/unpackfs/tests/9.job create mode 100644 calamares/src/modules/unpackfs/unpackfs.conf create mode 100644 calamares/src/modules/unpackfs/unpackfs.schema.yaml create mode 100644 calamares/src/modules/unpackfsc/CMakeLists.txt create mode 100644 calamares/src/modules/unpackfsc/ErofsRunner.cpp create mode 100644 calamares/src/modules/unpackfsc/ErofsRunner.h create mode 100644 calamares/src/modules/unpackfsc/FSArchiverRunner.cpp create mode 100644 calamares/src/modules/unpackfsc/FSArchiverRunner.h create mode 100644 calamares/src/modules/unpackfsc/Runners.cpp create mode 100644 calamares/src/modules/unpackfsc/Runners.h create mode 100644 calamares/src/modules/unpackfsc/TarballRunner.cpp create mode 100644 calamares/src/modules/unpackfsc/TarballRunner.h create mode 100644 calamares/src/modules/unpackfsc/UnpackFSCJob.cpp create mode 100644 calamares/src/modules/unpackfsc/UnpackFSCJob.h create mode 100644 calamares/src/modules/unpackfsc/UnsquashRunner.cpp create mode 100644 calamares/src/modules/unpackfsc/UnsquashRunner.h create mode 100644 calamares/src/modules/unpackfsc/tests/1.global create mode 100644 calamares/src/modules/unpackfsc/tests/1.job create mode 100644 calamares/src/modules/unpackfsc/unpackfsc.conf create mode 100644 calamares/src/modules/unpackfsc/unpackfsc.schema.yaml create mode 100644 calamares/src/modules/users/ActiveDirectoryJob.cpp create mode 100644 calamares/src/modules/users/ActiveDirectoryJob.h create mode 100644 calamares/src/modules/users/CMakeLists.txt create mode 100644 calamares/src/modules/users/CheckPWQuality.cpp create mode 100644 calamares/src/modules/users/CheckPWQuality.h create mode 100644 calamares/src/modules/users/Config.cpp create mode 100644 calamares/src/modules/users/Config.h create mode 100644 calamares/src/modules/users/CreateUserJob.cpp create mode 100644 calamares/src/modules/users/CreateUserJob.h create mode 100644 calamares/src/modules/users/MiscJobs.cpp create mode 100644 calamares/src/modules/users/MiscJobs.h create mode 100644 calamares/src/modules/users/SetHostNameJob.cpp create mode 100644 calamares/src/modules/users/SetHostNameJob.h create mode 100644 calamares/src/modules/users/SetPasswordJob.cpp create mode 100644 calamares/src/modules/users/SetPasswordJob.h create mode 100644 calamares/src/modules/users/TestGroupInformation.cpp create mode 100644 calamares/src/modules/users/TestPasswordJob.cpp create mode 100644 calamares/src/modules/users/TestSetHostNameJob.cpp create mode 100644 calamares/src/modules/users/Tests.cpp create mode 100644 calamares/src/modules/users/UsersPage.cpp create mode 100644 calamares/src/modules/users/UsersPage.h create mode 100644 calamares/src/modules/users/UsersViewStep.cpp create mode 100644 calamares/src/modules/users/UsersViewStep.h create mode 100644 calamares/src/modules/users/images/invalid.png create mode 100644 calamares/src/modules/users/images/invalid.png.license create mode 100644 calamares/src/modules/users/images/valid.png create mode 100644 calamares/src/modules/users/images/valid.png.license create mode 100644 calamares/src/modules/users/page_usersetup.ui create mode 100644 calamares/src/modules/users/tests/3-wing.conf create mode 100644 calamares/src/modules/users/tests/4-audio.conf create mode 100644 calamares/src/modules/users/tests/5-issue-1523.conf create mode 100644 calamares/src/modules/users/tests/6a-issue-1672.conf create mode 100644 calamares/src/modules/users/tests/6b-issue-1672.conf create mode 100644 calamares/src/modules/users/tests/6c-issue-1672.conf create mode 100644 calamares/src/modules/users/tests/6d-issue-1672.conf create mode 100644 calamares/src/modules/users/tests/6e-issue-1672.conf create mode 100644 calamares/src/modules/users/tests/7an-shell.conf create mode 100644 calamares/src/modules/users/tests/7ao-shell.conf create mode 100644 calamares/src/modules/users/tests/7bn-shell.conf create mode 100644 calamares/src/modules/users/tests/7bo-shell.conf create mode 100644 calamares/src/modules/users/tests/7cn-shell.conf create mode 100644 calamares/src/modules/users/tests/7co-shell.conf create mode 100644 calamares/src/modules/users/tests/7dn-shell.conf create mode 100644 calamares/src/modules/users/tests/7do-shell.conf create mode 100644 calamares/src/modules/users/tests/7en-shell.conf create mode 100644 calamares/src/modules/users/tests/7eo-shell.conf create mode 100644 calamares/src/modules/users/tests/7fb-shell.conf create mode 100644 calamares/src/modules/users/tests/7fn-shell.conf create mode 100644 calamares/src/modules/users/tests/7fo-shell.conf create mode 100644 calamares/src/modules/users/tests/8a-issue-2362.conf create mode 100644 calamares/src/modules/users/tests/8b-issue-2362.conf create mode 100644 calamares/src/modules/users/tests/8c-issue-2362.conf create mode 100644 calamares/src/modules/users/tests/8d-issue-2362.conf create mode 100644 calamares/src/modules/users/tests/8e-issue-2362.conf create mode 100644 calamares/src/modules/users/tests/8f-issue-2362.conf create mode 100644 calamares/src/modules/users/tests/8g-issue-2362.conf create mode 100644 calamares/src/modules/users/tests/8h-issue-2362.conf create mode 100644 calamares/src/modules/users/users.conf create mode 100644 calamares/src/modules/users/users.qrc create mode 100644 calamares/src/modules/users/users.schema.yaml create mode 100644 calamares/src/modules/usersq/CMakeLists.txt create mode 100644 calamares/src/modules/usersq/UsersQmlViewStep.cpp create mode 100644 calamares/src/modules/usersq/UsersQmlViewStep.h create mode 100644 calamares/src/modules/usersq/usersq-qt6.qml create mode 100644 calamares/src/modules/usersq/usersq-qt6.qrc create mode 100644 calamares/src/modules/usersq/usersq.conf create mode 100644 calamares/src/modules/usersq/usersq.qml create mode 100644 calamares/src/modules/usersq/usersq.qrc create mode 100644 calamares/src/modules/welcome/CMakeLists.txt create mode 100644 calamares/src/modules/welcome/Config.cpp create mode 100644 calamares/src/modules/welcome/Config.h create mode 100644 calamares/src/modules/welcome/Tests.cpp create mode 100644 calamares/src/modules/welcome/WelcomePage.cpp create mode 100644 calamares/src/modules/welcome/WelcomePage.h create mode 100644 calamares/src/modules/welcome/WelcomePage.ui create mode 100644 calamares/src/modules/welcome/WelcomeViewStep.cpp create mode 100644 calamares/src/modules/welcome/WelcomeViewStep.h create mode 100644 calamares/src/modules/welcome/checker/CheckerContainer.cpp create mode 100644 calamares/src/modules/welcome/checker/CheckerContainer.h create mode 100644 calamares/src/modules/welcome/checker/GeneralRequirements.cpp create mode 100644 calamares/src/modules/welcome/checker/GeneralRequirements.h create mode 100644 calamares/src/modules/welcome/checker/ResultDelegate.cpp create mode 100644 calamares/src/modules/welcome/checker/ResultDelegate.h create mode 100644 calamares/src/modules/welcome/checker/ResultsListWidget.cpp create mode 100644 calamares/src/modules/welcome/checker/ResultsListWidget.h create mode 100644 calamares/src/modules/welcome/checker/partman_devices.c create mode 100644 calamares/src/modules/welcome/checker/partman_devices.h create mode 100644 calamares/src/modules/welcome/language-icon-128px.png create mode 100644 calamares/src/modules/welcome/language-icon-128px.png.license create mode 100644 calamares/src/modules/welcome/language-icon-48px.png create mode 100644 calamares/src/modules/welcome/language-icon-48px.png.license create mode 100644 calamares/src/modules/welcome/tests/1a-checkinternet.conf create mode 100644 calamares/src/modules/welcome/tests/1b-checkinternet.conf create mode 100644 calamares/src/modules/welcome/tests/1c-checkinternet.conf create mode 100644 calamares/src/modules/welcome/tests/1d-checkinternet.conf create mode 100644 calamares/src/modules/welcome/tests/1e-checkinternet.conf create mode 100644 calamares/src/modules/welcome/tests/1f-checkinternet.conf create mode 100644 calamares/src/modules/welcome/tests/1g-checkinternet.conf create mode 100644 calamares/src/modules/welcome/tests/1h-checkinternet.conf create mode 100644 calamares/src/modules/welcome/welcome.conf create mode 100644 calamares/src/modules/welcome/welcome.qrc create mode 100644 calamares/src/modules/welcome/welcome.schema.yaml create mode 100644 calamares/src/modules/welcomeq/CMakeLists.txt create mode 100644 calamares/src/modules/welcomeq/Recommended.qml create mode 100644 calamares/src/modules/welcomeq/Requirements.qml create mode 100644 calamares/src/modules/welcomeq/WelcomeQmlViewStep.cpp create mode 100644 calamares/src/modules/welcomeq/WelcomeQmlViewStep.h create mode 100644 calamares/src/modules/welcomeq/img/chevron-left-solid.svg create mode 100644 calamares/src/modules/welcomeq/img/chevron-left-solid.svg.license create mode 100644 calamares/src/modules/welcomeq/img/language-icon-48px.png create mode 100644 calamares/src/modules/welcomeq/img/language-icon-48px.png.license create mode 100644 calamares/src/modules/welcomeq/img/squid.png create mode 100644 calamares/src/modules/welcomeq/img/squid.png.license create mode 100644 calamares/src/modules/welcomeq/release_notes.qml create mode 100644 calamares/src/modules/welcomeq/welcomeq-qt6.qml create mode 100644 calamares/src/modules/welcomeq/welcomeq-qt6.qrc create mode 100644 calamares/src/modules/welcomeq/welcomeq.conf create mode 100644 calamares/src/modules/welcomeq/welcomeq.qml create mode 100644 calamares/src/modules/welcomeq/welcomeq.qrc create mode 100644 calamares/src/modules/zfs/CMakeLists.txt create mode 100644 calamares/src/modules/zfs/README.md create mode 100644 calamares/src/modules/zfs/ZfsJob.cpp create mode 100644 calamares/src/modules/zfs/ZfsJob.h create mode 100644 calamares/src/modules/zfs/zfs.conf create mode 100644 calamares/src/modules/zfs/zfs.schema.yaml create mode 100644 calamares/src/modules/zfshostid/main.py create mode 100644 calamares/src/modules/zfshostid/module.desc create mode 100644 calamares/src/modules/zfshostid/zfshostid.schema.yaml create mode 100644 calamares/src/qml/CMakeLists.txt create mode 100644 calamares/src/qml/calamares-qt5/CMakeLists.txt create mode 100644 calamares/src/qml/calamares-qt5/slideshow/BackButton.qml create mode 100644 calamares/src/qml/calamares-qt5/slideshow/ForwardButton.qml create mode 100644 calamares/src/qml/calamares-qt5/slideshow/NavButton.qml create mode 100644 calamares/src/qml/calamares-qt5/slideshow/Presentation.qml create mode 100644 calamares/src/qml/calamares-qt5/slideshow/Slide.qml create mode 100644 calamares/src/qml/calamares-qt5/slideshow/SlideCounter.qml create mode 100644 calamares/src/qml/calamares-qt5/slideshow/qmldir create mode 100644 calamares/src/qml/calamares-qt5/slideshow/qmldir.license create mode 100644 calamares/src/qml/calamares-qt6/CMakeLists.txt create mode 100644 calamares/src/qml/calamares-qt6/slideshow/BackButton.qml create mode 100644 calamares/src/qml/calamares-qt6/slideshow/ForwardButton.qml create mode 100644 calamares/src/qml/calamares-qt6/slideshow/NavButton.qml create mode 100644 calamares/src/qml/calamares-qt6/slideshow/Presentation.qml create mode 100644 calamares/src/qml/calamares-qt6/slideshow/Slide.qml create mode 100644 calamares/src/qml/calamares-qt6/slideshow/SlideCounter.qml create mode 100644 calamares/src/qml/calamares-qt6/slideshow/qmldir create mode 100644 calamares/src/qml/calamares-qt6/slideshow/qmldir.license diff --git a/.gitignore b/.gitignore index e826d17..4153d41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -*.pkg.tar.zst -*.zip -pkg/ -src/ +/*.pkg.tar.zst +/*.zip +/pkg/ +/src/ diff --git a/1-get-all-alci-gits-v1.sh b/1-get-all-alci-gits-v1.sh new file mode 100755 index 0000000..506e2a3 --- /dev/null +++ b/1-get-all-alci-gits-v1.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# +################################################################################################################## +# Written to be used on 64 bits computers +# Author : Erik Dubois +# Website : http://www.erikdubois.be +################################################################################################################## +################################################################################################################## +# +# DO NOT JUST RUN THIS. EXAMINE AND JUDGE. RUN AT YOUR OWN RISK. +# +################################################################################################################## + +echo "This gets all the existing githubs at once" +echo "Fill the array with the original folders first" + +# use ls -d */ > list to get the list of the created githubs and copy/paste in + +directories=( +tms-calamares-config/ +tms-grub-theme/ +tms-arch-linux-calamares-installer/ +tms-calamares/ +) + +count=0 + +for name in "${directories[@]}"; do + count=$[count+1] + tput setaf 1;echo "Github "$count;tput sgr0; + # if there is no folder then make one + git clone http://192.168.10.207:3000/zj/$name + echo "#################################################" + echo "################ "$(basename `pwd`)" done" + echo "#################################################" +done diff --git a/calamares/src/CMakeLists.txt b/calamares/src/CMakeLists.txt new file mode 100644 index 0000000..e342c96 --- /dev/null +++ b/calamares/src/CMakeLists.txt @@ -0,0 +1,26 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +include(CalamaresAddBrandingSubdirectory) +include(CalamaresAddLibrary) +include(CalamaresAddModuleSubdirectory) +include(CalamaresAddPlugin) +include(CalamaresAddTest) +include(CalamaresAddTranslations) + +# library +add_subdirectory(libcalamares) +add_subdirectory(libcalamaresui) + +add_subdirectory(qml) + +# application +add_subdirectory(calamares) + +# plugins +add_subdirectory(modules) + +# branding components +add_subdirectory(branding) diff --git a/calamares/src/branding/CMakeLists.txt b/calamares/src/branding/CMakeLists.txt new file mode 100644 index 0000000..09d6011 --- /dev/null +++ b/calamares/src/branding/CMakeLists.txt @@ -0,0 +1,10 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# Add branding components. Since there is only one, called "default", +# add that one. For examples of other branding components, see +# the calamares-extensions repository. +calamares_add_branding_subdirectory( default ) diff --git a/calamares/src/branding/README.md b/calamares/src/branding/README.md new file mode 100644 index 0000000..52d47c9 --- /dev/null +++ b/calamares/src/branding/README.md @@ -0,0 +1,225 @@ +# Branding directory + + + +Branding components can go here, or they can be installed separately. + +A branding component is a subdirectory with a `branding.desc` descriptor +file, containing brand-specific strings in a key-value structure, plus +brand-specific images or QML. Such a subdirectory, when placed here, is +automatically picked up by CMake and made available to Calamares. + +It is recommended to package branding separately, so as to avoid +forking Calamares just for adding some files. Calamares installs +CMake support macros to help create branding packages. See the +calamares-branding repository for examples of stand-alone branding. + + +## Examples + +There is one example of a branding component included with Calamares, +so that it can be run directly from the build directory for testing purposes: + + - `default/` is a sample brand for the Generic Linux distribution. It uses + the default Calamares icons and a as start-page splash it provides a + tag-cloud view of languages. The slideshow is a basic one with a few + slides of text and a single image. Translations (done by hand, not via + the usual mechanism of Calamares translations) in English, Arabic, Dutch + and French are available. + +Since the slideshow can be **any** QML, it is limited only by your designers +imagination and your QML experience. For straightforward presentations, +see the documentation below. There are more examples in the [calamares-branding][1] +repository. + +[1] https://codeberg.org/Calamares/calamares-branding + + +## API Versions + +In Calamares versions prior to 3.2.10, the QML slideshow was loaded +synchronously when the installation page is shown. This can lead to +noticeable lag when showing that page. The QML is written start when +it is loaded, by responding to the `onComplete` signal. + +Calamares 3.2.10 introduces an API versioning scheme which uses different +loading mechanisms. + + - **API version 1** Loads the QML slideshow synchronously, as before. + - The QML can use `onComplete` to start timers, etc. for progress + or animation. + - Translations are supported through `qsTr()` and the language that is + in use when the installation slideshow is loaded, will be used + (once the installation part is running, it can't change anyway). + - **API version 2** Loads the QML slideshow **a**synchronously, on + startup (generally during the requirements-checking phase of Calamares) + so that no compilation lag is seen. + - The QML should **not** use `onComplete`, since the QML is loaded and + instantiated at startup. Instead, + - The QML should provide functions `onActivate()` and `onLeave()` in the + root object of the slideshow. These are called when the slideshow + should start (e.g. becomes visible) and stop. + - Translations are supported through `qsTr()`. However, since the language + can change after the QML is loaded, code should count on the bindings + being re-evaluated on language change. Translation updates (e.g. change + of language) is **only supported** with Qt 5.10 and later. + +The setting *slideshowAPI* in `branding.desc` indicates which one to use +for a given branding slideshow. Which API to use is really a function of +the QML. Expect the version 1 API to be deprecated in the course of Calamares 3.3. + +In Calamares 3.2.13 support for activation notification to the QML +parts is improved: + - If the root object has a property *activatedInCalamares* (the examples do), + then that property is set to *true* when the slideshow becomes visible + (activated) and is set to *false* when the slideshow is hidden (e.g. + when the installation phase is done). + - The *actvatedInCalamares* property can be used to set up timers also in V1. + - The keyboard shortcuts in the example slideshow are enabled only while + the slideshow is visible. + + +## Translations + +QML files in a branding component can be translated. Translations should +be placed in a subdirectory `lang/` of the branding component directory. +Qt translation files are supported (`.ts` sources which get compiled into +`.qm`). Inside the `lang` subdirectory all translation files must be named +according to the scheme `calamares-_.ts`. + +The example branding component, called *default*, therefore has translation +files names `calamares-default_nl.ts` (similar for other languages than Dutch). + +Text in your `show.qml` (or whatever *slideshow* is set to in the descriptor +file) should be enclosed in this form for translations + +``` + text: qsTr("This is an example text.") +``` + +If you use CMake for preparing branding for packaging, the macro +`calamares_add_branding_subdirectory()`` (see also *Project Layout*, +below) will convert the source `.ts` files to their compiled form). +If you are packaging the branding by hand, use +``` + lrelease file_en.ts [file_en_GB.ts ..] +``` +with all the language suffixes to *file*. + + +## Presentation + +The default QML classes provided by Calamares can be used for a simple +and straightforward "slideshow" presentation with static text and +pictures. To use the default slideshow classes, start with a `show.qml` +file with the following content: + +``` +import QtQuick 2.5; +import calamares.slideshow 1.0; + +Presentation +{ + id: presentation +} +``` + +After the *id*, set properties of the presentation as a whole. These include: + - *loopSlides* (default true) When set, clicking past the last slide + returns to the very first slide. + - *mouseNavigation*, *arrowNavigation*, *keyShortcutsEnabled* (all default + true) enable different ways to navigate the slideshow. + - *titleColor*, *textColor* change the look of the presentation. + - *fontFamily*, *codeFontFamily* change the look of text in the presentation. + +After setting properties, you can add elements to the presentation. +Generally, you will add a few presentation-level elements first, +then slides. + - For visible navigation arrows, add elements of class *ForwardButton* and + *BackwardButton*. Set the *source* property of each to a suitable + image. See the `fancy/` example in the external branding-examples + repository. It is recommended to turn off other + kinds of navigation when visible navigation is used. + - To indicate where the user is, add an element of class *SlideCounter*. + This indicates in "n / total" form where the user is in the slideshow. + - To automatically advance the presentation (for a fully passive slideshow), + add a timer that calls the `goToNextSlide()` function of the presentation. + See the `default/` example -- remember to start the timer when the + presentation is completely loaded. + +After setting the presentation elements, add one or more Slide elements. +The presentation framework will make a slideshow out of the Slide +elements, displaying only one at a time. Each slide is an element in itself, +so you can put whatever visual elements you like in the slide. They have +standard properties for a boring "static text" slideshow, though: + - *title* is text to show as slide title + - *centeredText* is displayed in a large-ish font + - *writeInText* is displayed by "writing it in" to the slide, + one letter at a time. + - *content* is a list of things which are displayed as a bulleted list. + +The presentation classes can be used to produce a fairly dry slideshow +for the installation process; it is recommended to experiment with the +visual effects and classes available in QtQuick. + + +## Project Layout + +A branding component that is created and installed outside of Calamares +will have a top-level `CMakeLists.txt` that includes some boilerplate +to find Calamares, and then adds a subdirectory which contains the +actual branding component. + +The file layout in a typical branding component repository is: + +``` + / + - CMakeLists.txt + - componentname/ + - show.qml + - image1.png + ... + - lang/ + - calamares-componentname_en.ts + - calamares-componentname_de.ts + ... +``` + +Adding the subdirectory can be done as follows: + + - If the directory contains files only, and optionally has a single + subdirectory lang/ which contains the translation files for the + component, then `calamares_add_branding_subdirectory()` can be + used, which takes only the name of the subdirectory. + - If the branding component has many files which are organized into + subdirectories, use the SUBDIRECTORIES argument to the CMake function + to additionally install files from those subdirectories. For example, + if the component places all of its images in an `img/` subdirectory, + then call `calamares_add_branding_subdirectory( ... SUBDIRECTORIES img)`. + It is a bad idea to include `lang/` in the SUBDIRECTORIES list. + - The `.ts` files from the `lang/` subdirectory need be be compiled + to `.qm` files before being installed. The CMake macro's do this + automatically. For manual packaging, use `lrelease` to compile + the files. + +## Global Storage keys + +The following keys from the `branding.desc` file are copied into +Global Storage under a *branding* parent key: + "productName", + "version", + "shortVersion", + "versionedName", + "shortVersionedName", + "shortProductName", + "bootloaderEntryName", + "productUrl", + "supportUrl", + "knownIssuesUrl", + "releaseNotesUrl", + "donateUrl" + + diff --git a/calamares/src/branding/default/banner.png b/calamares/src/branding/default/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..d1baeee85569fac787067c199f2dcb93738597da GIT binary patch literal 5937 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 y#rQT +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/branding/default/branding.desc b/calamares/src/branding/default/branding.desc new file mode 100644 index 0000000..eed9802 --- /dev/null +++ b/calamares/src/branding/default/branding.desc @@ -0,0 +1,239 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Product branding information. This influences some global +# user-visible aspects of Calamares, such as the product +# name, window behavior, and the slideshow during installation. +# +# Additional styling can be done using the stylesheet.qss +# file, also in the branding directory. +--- +componentName: default + + +### WELCOME / OVERALL WORDING +# +# These settings affect some overall phrasing and looks, +# which are most visible in the welcome page. + +# This selects between different welcome texts. When false, uses +# the traditional "Welcome to the %1 installer.", and when true, +# uses "Welcome to the Calamares installer for %1." This allows +# to distinguish this installer from other installers for the +# same distribution. +welcomeStyleCalamares: false + +# Should the welcome image (productWelcome, below) be scaled +# up beyond its natural size? If false, the image does not grow +# with the window but remains the same size throughout (this +# may have surprising effects on HiDPI monitors). +welcomeExpandingLogo: true + +### WINDOW CONFIGURATION +# +# The settings here affect the placement of the Calamares +# window through hints to the window manager and initial +# sizing of the Calamares window. + +# Size and expansion policy for Calamares. +# - "normal" or unset, expand as needed, use *windowSize* +# - "fullscreen", start as large as possible, ignore *windowSize* +# - "noexpand", don't expand automatically, use *windowSize* +windowExpanding: normal + +# Size of Calamares window, expressed as w,h. Both w and h +# may be either pixels (suffix px) or font-units (suffix em). +# e.g. "800px,600px" +# "60em,480px" +# This setting is ignored if "fullscreen" is selected for +# *windowExpanding*, above. If not set, use constants defined +# in CalamaresUtilsGui, 800x520. +windowSize: 800px,520px + +# Placement of Calamares window. Either "center" or "free". +# Whether "center" actually works does depend on the window +# manager in use (and only makes sense if you're not using +# *windowExpanding* set to "fullscreen"). +windowPlacement: center + +### PANELS CONFIGURATION +# +# Calamares has a main content area, and two panels (navigation +# and progress / sidebar). The panels can be controlled individually, +# or switched off. If both panels are switched off, the layout of +# the main content area loses its margins, on the assumption that +# you're doing something special. + +# Kind of sidebar (panel on the left, showing progress). +# - "widget" or unset, use traditional sidebar (logo, items) +# - "none", hide it entirely +# - "qml", use calamares-sidebar.qml from branding folder +# In addition, you **may** specify a side, separated by a comma, +# from the kind. Valid sides are: +# - "left" (if not specified, uses this) +# - "right" +# - "top" +# - "bottom" +# For instance, "widget,right" is valid; so is "qml", which defaults +# to putting the sidebar on the left. Also valid is "qml,top". +# While "widget,top" is valid, the widgets code is **not** flexible +# and results will be terrible. +sidebar: widget + +# Kind of navigation (button panel on the bottom). +# - "widget" or unset, use traditional navigation +# - "none", hide it entirely +# - "qml", use calamares-navigation.qml from branding folder +# In addition, you **may** specify a side, separated by a comma, +# from the kind. The same sides are valid as for *sidebar*, +# except the default is *bottom*. +navigation: widget + + +### STRINGS, IMAGES AND COLORS +# +# This section contains the "branding proper" of names +# and images, rather than global-look settings. + +# These are strings shown to the user in the user interface. +# There is no provision for translating them -- since they +# are names, the string is included as-is. +# +# The four Url strings are the Urls used by the buttons in +# the welcome screen, and are not shown to the user. Clicking +# on the "Support" button, for instance, opens the link supportUrl. +# If a Url is empty, the corresponding button is not shown. +# +# bootloaderEntryName is how this installation / distro is named +# in the boot loader (e.g. in the GRUB menu). +# +# These strings support substitution from /etc/os-release +# if KDE Frameworks 5.58 are available at build-time. When +# enabled, ${varname} is replaced by the equivalent value +# from os-release. All the supported var-names are in all-caps, +# and are listed on the FreeDesktop.org site, +# https://www.freedesktop.org/software/systemd/man/os-release.html +# Note that ANSI_COLOR and CPE_NAME don't make sense here, and +# are not supported (the rest are). Remember to quote the string +# if it contains substitutions, or you'll get YAML exceptions. +# +# The *Url* entries are used on the welcome page, and they +# are visible as buttons there if the corresponding *show* keys +# are set to "true" (they can also be overridden). +strings: + productName: "${NAME}" + shortProductName: Generic + version: 2023.3 LTS + shortVersion: 2023.3 + versionedName: Fancy GNU/Linux 2023.3 LTS "Venomous Vole" + shortVersionedName: FancyGL 2023.3 + bootloaderEntryName: FancyGL + productUrl: https://calamares.io/ + supportUrl: https://codeberg.org/Calamares/calamares/wiki + knownIssuesUrl: https://codeberg.org/Calamares/calamares/issues + releaseNotesUrl: https://calamares.io/news/ + donateUrl: https://docs.codeberg.org/improving-codeberg/donate/ + +# These images are loaded from the branding module directory. +# +# productBanner is an optional image, which if present, will be shown +# on the welcome page of the application, above the welcome text. +# It is intended to have a width much greater than height. +# It is displayed at 64px height (also on HiDPI). +# Recommended size is 64px tall, and up to 460px wide. +# productIcon is used as the window icon, and will (usually) be used +# by the window manager to represent the application. This image +# should be square, and may be displayed by the window manager +# as small as 16x16 (but possibly larger). +# productLogo is used as the logo at the top of the left-hand column +# which shows the steps to be taken. The image should be square, +# and is displayed at 80x80 pixels (also on HiDPI). +# productWallpaper is an optional image, which if present, will replace +# the normal solid background on every page of the application. +# It can be any size and proportion, +# and will be tiled to fit the entire window. +# For a non-tiled wallpaper, the size should be the same as +# the overall window, see *windowSize* above (800x520). +# productWelcome is shown on the welcome page of the application in +# the middle of the window, below the welcome text. It can be +# any size and proportion, and will be scaled to fit inside +# the window. Use `welcomeExpandingLogo` to make it non-scaled. +# Recommended size is 320x150. +# +# These filenames can also use substitutions from os-release (see above). +images: + # productBanner: "banner.png" + productIcon: "squid.png" + productLogo: "squid.png" + # productWallpaper: "wallpaper.png" + productWelcome: "languages.png" + +# Colors for text and background components. +# +# - SidebarBackground is the background of the sidebar +# - SidebarText is the (foreground) text color +# - SidebarBackgroundCurrent sets the background of the current step. +# Optional, and defaults to the application palette. +# - SidebarTextCurrent is the text color of the current step. +# +# These colors can **also** be set through the stylesheet, if the +# branding component also ships a stylesheet.qss. Then they are +# the corresponding CSS attributes of #sidebarApp. +style: + SidebarBackground: "#292F34" + SidebarText: "#FFFFFF" + SidebarTextCurrent: "#292F34" + SidebarBackgroundCurrent: "#D35400" + +### SLIDESHOW +# +# The slideshow is displayed during execution steps (e.g. when the +# installer is actually writing to disk and doing other slow things). + +# The slideshow can be a QML file (recommended) which can display +# arbitrary things -- text, images, animations, or even play a game -- +# during the execution step. The QML **is** abruptly stopped when the +# execution step is done, though, so maybe a game isn't a great idea. +# +# The slideshow can also be a sequence of images (not recommended unless +# you don't want QML at all in your Calamares). The images are displayed +# at a rate of 1 every 2 seconds during the execution step. +# +# To configure a QML file, list a single filename: +# slideshow: "show.qml" +# To configure images, like the filenames (here, as an inline list): +# slideshow: [ "/etc/calamares/slideshow/0.png", "/etc/logo.png" ] +slideshow: "show.qml" + +# There are two available APIs for a QML slideshow: +# - 1 (the default) loads the entire slideshow when the installation- +# slideshow page is shown and starts the QML then. The QML +# is never stopped (after installation is done, times etc. +# continue to fire). +# - 2 loads the slideshow on startup and calls onActivate() and +# onLeave() in the root object. After the installation is done, +# the show is stopped (first by calling onLeave(), then destroying +# the QML components). +# +# An image slideshow does not need to have the API defined. +slideshowAPI: 2 + + +# These options are to customize online uploading of logs to pastebins: +# - type : Defines the kind of pastebin service to be used. Currently +# it accepts two values: +# - none : disables the pastebin functionality +# - fiche : use fiche pastebin server +# - url : Defines the address of pastebin service to be used. +# Takes string as input. Important bits are the host and port, +# the scheme is not used. +# - sizeLimit : Defines maximum size limit (in KiB) of log file to be pasted. +# The option must be set, to have the log option work. +# Takes integer as input. If < 0, no limit will be forced, +# else only last (approximately) 'n' KiB of log file will be pasted. +# Please note that upload size may be slightly over the limit (due +# to last minute logging), so provide a suitable value. +uploadServer : + type : "fiche" + url : "http://termbin.com:9999" + sizeLimit : -1 diff --git a/calamares/src/branding/default/lang/calamares-default_ar.ts b/calamares/src/branding/default/lang/calamares-default_ar.ts new file mode 100644 index 0000000..3c4fe09 --- /dev/null +++ b/calamares/src/branding/default/lang/calamares-default_ar.ts @@ -0,0 +1,17 @@ + + + + + show + + + This is a second Slide element. + عرض الثاني + + + + This is a third Slide element. + عرض الثالث + + + diff --git a/calamares/src/branding/default/lang/calamares-default_en.ts b/calamares/src/branding/default/lang/calamares-default_en.ts new file mode 100644 index 0000000..b02dbd5 --- /dev/null +++ b/calamares/src/branding/default/lang/calamares-default_en.ts @@ -0,0 +1,17 @@ + + + + + show + + + This is a second Slide element. + + + + + This is a third Slide element. + + + + diff --git a/calamares/src/branding/default/lang/calamares-default_eo.ts b/calamares/src/branding/default/lang/calamares-default_eo.ts new file mode 100644 index 0000000..7d1ef4e --- /dev/null +++ b/calamares/src/branding/default/lang/calamares-default_eo.ts @@ -0,0 +1,17 @@ + + + + + show + + + This is a second Slide element. + Ĉi tio estas la dua gliteja. + + + + This is a third Slide element. + Ĉi tio estas la tria gliteja. + + + diff --git a/calamares/src/branding/default/lang/calamares-default_fr.ts b/calamares/src/branding/default/lang/calamares-default_fr.ts new file mode 100644 index 0000000..ec5e041 --- /dev/null +++ b/calamares/src/branding/default/lang/calamares-default_fr.ts @@ -0,0 +1,17 @@ + + + + + show + + + This is a second Slide element. + Ceci est la deuxieme affiche. + + + + This is a third Slide element. + La troisième affice ce trouve ici. + + + diff --git a/calamares/src/branding/default/lang/calamares-default_nl.ts b/calamares/src/branding/default/lang/calamares-default_nl.ts new file mode 100644 index 0000000..19fd583 --- /dev/null +++ b/calamares/src/branding/default/lang/calamares-default_nl.ts @@ -0,0 +1,17 @@ + + + + + show + + + This is a second Slide element. + Dit is het tweede Dia element. + + + + This is a third Slide element. + Dit is het derde Dia element. + + + diff --git a/calamares/src/branding/default/languages.png b/calamares/src/branding/default/languages.png new file mode 100644 index 0000000000000000000000000000000000000000..53316526bc8039e4e05d2860ae5767ccbde9eb4f GIT binary patch literal 86002 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�==0;j%F=Kq0P>U~X0$-7~AA^Z= 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 M07*qoM6N<$g8o@6NdN!< literal 0 HcmV?d00001 diff --git a/calamares/src/branding/default/languages.png.license b/calamares/src/branding/default/languages.png.license new file mode 100644 index 0000000..ea82645 --- /dev/null +++ b/calamares/src/branding/default/languages.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2015 Teo Mrnjavac +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/branding/default/show.qml b/calamares/src/branding/default/show.qml new file mode 100644 index 0000000..f4c50e6 --- /dev/null +++ b/calamares/src/branding/default/show.qml @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import QtQuick 2.0; +import calamares.slideshow 1.0; + +Presentation +{ + id: presentation + + function nextSlide() { + console.log("QML Component (default slideshow) Next slide"); + presentation.goToNextSlide(); + } + + Timer { + id: advanceTimer + interval: 1000 + running: presentation.activatedInCalamares + repeat: true + onTriggered: nextSlide() + } + + Slide { + + Image { + id: background + source: "squid.png" + width: 200; height: 200 + fillMode: Image.PreserveAspectFit + anchors.centerIn: parent + } + Text { + anchors.horizontalCenter: background.horizontalCenter + anchors.top: background.bottom + text: "This is a customizable QML slideshow.
"+ + "Distributions should provide their own slideshow and list it in
"+ + "their custom branding.desc file.
"+ + "To create a Calamares presentation in QML, import calamares.slideshow,
"+ + "define a Presentation element with as many Slide elements as needed." + wrapMode: Text.WordWrap + width: presentation.width + horizontalAlignment: Text.Center + } + } + + Slide { + centeredText: qsTr("This is a second Slide element.") + } + + Slide { + centeredText: qsTr("This is a third Slide element.") + } + + // When this slideshow is loaded as a V1 slideshow, only + // activatedInCalamares is set, which starts the timer (see above). + // + // In V2, also the onActivate() and onLeave() methods are called. + // These example functions log a message (and re-start the slides + // from the first). + function onActivate() { + console.log("QML Component (default slideshow) activated"); + presentation.currentSlide = 0; + } + + function onLeave() { + console.log("QML Component (default slideshow) deactivated"); + } + +} diff --git a/calamares/src/branding/default/squid.png b/calamares/src/branding/default/squid.png new file mode 100644 index 0000000000000000000000000000000000000000..452e4450c56c10cda33dcc9c5d03753ace458862 GIT binary patch literal 8313 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 +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/branding/default/stylesheet.qss b/calamares/src/branding/default/stylesheet.qss new file mode 100644 index 0000000..5c36738 --- /dev/null +++ b/calamares/src/branding/default/stylesheet.qss @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: no + * SPDX-License-Identifier: CC0-1.0 + */ + +/* +A branding component can ship a stylesheet (like this one) +which is applied to parts of the Calamares user-interface. +In principle, all parts can be styled through CSS. +Missing parts should be filed as issues. + +The IDs are based on the object names in the C++ code. +You can use the Debug Dialog to find out object names: + - Open the debug dialog + - Choose tab *Tools* + - Click *Widget Tree* button +The list of object names is printed in the log. + +Documentation for styling Qt Widgets through a stylesheet +can be found at + https://doc.qt.io/qt-5/stylesheet-examples.html + https://doc.qt.io/qt-5/stylesheet-reference.html +In Calamares, styling widget classes is supported (e.g. +using `QComboBox` as a selector). + +This example stylesheet has all the actual styling commented out. +The examples are not exhaustive. + +*/ + +/*** Generic Widgets. + * + * You can style **all** widgets of a given class by selecting + * the class name. Some widgets have specialized sub-selectors. + */ + +/* +QPushButton { background-color: green; } +*/ + +/*** Main application window. + * + * The main application window has the sidebar, which in turn + * contains a logo and a list of items -- note that the list + * can **not** be styled, since it has its own custom C++ + * delegate code. + */ + +/* +#mainApp { } +#sidebarApp { } +#logoApp { } +*/ + +/*** Welcome module. + * + * There are plenty of parts, but the buttons are the most interesting + * ones (donate, release notes, ...). The little icon image can be + * styled through *qproperty-icon*, which is a little obscure. + * URLs can reference the QRC paths of the Calamares application + * or loaded via plugins or within the filesystem. There is no + * comprehensive list of available icons, though. + */ + +/* +QPushButton#aboutButton { qproperty-icon: url(:/data/images/release.svg); } +#donateButton, +#supportButton, +#releaseNotesButton, +#knownIssuesButton { qproperty-icon: url(:/data/images/help.svg); } +*/ + +/*** Partitioning module. + * + * Many moving parts, which you will need to experiment with. + */ + +/* +#bootInfoIcon { } +#bootInfoLable { } +#deviceInfoIcon { } +#defineInfoLabel { } +#scrollAreaWidgetContents { } +#partitionBarView { } +*/ + +/*** Licensing module. + * + * The licensing module paints individual widgets for each of + * the licenses. The item can be collapsed or expanded. + */ + +/* +#licenseItem { } +#licenseItemFullText { } +*/ diff --git a/calamares/src/calamares/CMakeLists.txt b/calamares/src/calamares/CMakeLists.txt new file mode 100644 index 0000000..3be6b92 --- /dev/null +++ b/calamares/src/calamares/CMakeLists.txt @@ -0,0 +1,67 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +set(calamaresSources + main.cpp + CalamaresApplication.cpp + CalamaresWindow.cpp + DebugWindow.cpp + VariantModel.cpp + progresstree/ProgressTreeDelegate.cpp + progresstree/ProgressTreeView.cpp +) + +include_directories( + ${CMAKE_SOURCE_DIR}/src/libcalamares + ${CMAKE_SOURCE_DIR}/src/libcalamaresui + ${CMAKE_BINARY_DIR}/src/libcalamares + ${CMAKE_CURRENT_SOURCE_DIR} +) + +### EXECUTABLE +# +# "calamares_bin" is the main application, not to be confused with +# the target "calamares" which is the non-GUI library part. +# +# The calamares-i18n.cxx file -- full path in CALAMARES_TRANSLATIONS_SOURCE -- +# is created as a target in the lang/ directory. This is compiled to a +# library (it's just the result of a QRC compile). +add_executable(calamares_bin ${calamaresSources} calamares.qrc) +target_include_directories(calamares_bin PRIVATE ${CMAKE_SOURCE_DIR}) +set_target_properties(calamares_bin PROPERTIES ENABLE_EXPORTS TRUE RUNTIME_OUTPUT_NAME calamares) +calamares_automoc( calamares_bin ) +calamares_autouic( calamares_bin ) +calamares_autorcc( calamares_bin ) + +target_link_libraries( + calamares_bin + PRIVATE calamares calamaresui calamares-i18n kdsingleapplication ${qtname}::Core ${qtname}::Widgets +) +target_link_libraries(calamares_bin PRIVATE ${kfname}::CoreAddons) +if(BUILD_CRASH_REPORTING) + target_link_libraries(calamares_bin PRIVATE ${kfname}::Crash) + target_compile_definitions(calamares_bin PRIVATE BUILD_CRASH_REPORTING) +endif() + +install(TARGETS calamares_bin BUNDLE DESTINATION . RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + +install( + FILES ${CMAKE_SOURCE_DIR}/data/images/squid.svg + RENAME calamares.svg + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps +) + +### TESTS +# +# +if(BUILD_TESTING) + # Don't install, these are just for enable_testing + add_executable(loadmodule testmain.cpp) + target_link_libraries(loadmodule PRIVATE ${qtname}::Core ${qtname}::Widgets calamares calamaresui) + + add_executable(test_conf test_conf.cpp) + target_link_libraries(test_conf PUBLIC yamlcpp::yamlcpp ${qtname}::Core) +endif() diff --git a/calamares/src/calamares/CalamaresApplication.cpp b/calamares/src/calamares/CalamaresApplication.cpp new file mode 100644 index 0000000..09d3bfc --- /dev/null +++ b/calamares/src/calamares/CalamaresApplication.cpp @@ -0,0 +1,302 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "CalamaresApplication.h" + +#include "CalamaresConfig.h" +#include "CalamaresVersionX.h" +#include "CalamaresWindow.h" +#include "progresstree/ProgressTreeView.h" + +#include "Branding.h" +#include "JobQueue.h" +#include "Settings.h" +#include "ViewManager.h" +#include "locale/TranslationsModel.h" +#include "modulesystem/ModuleManager.h" +#include "utils/Dirs.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/System.h" +#ifdef WITH_QML +#include "utils/Qml.h" +#endif +#include "utils/Retranslator.h" +#include "viewpages/ViewStep.h" + +#include +#include +#include +#include + +/// @brief Convenience for "are the settings in debug mode" +static bool +isDebug() +{ + return Calamares::Settings::instance() && Calamares::Settings::instance()->debugMode(); +} + +CalamaresApplication::CalamaresApplication( int& argc, char* argv[] ) + : QApplication( argc, argv ) + , m_mainwindow( nullptr ) + , m_moduleManager( nullptr ) +{ + // Setting the organization name makes the default cache + // directory -- where Calamares stores logs, for instance -- + // //, so we end up with ~/.cache/Calamares/calamares/ + // which is excessively squidly. + // + // setOrganizationName( QStringLiteral( CALAMARES_ORGANIZATION_NAME ) ); + setOrganizationDomain( QStringLiteral( CALAMARES_ORGANIZATION_DOMAIN ) ); + setApplicationName( QStringLiteral( CALAMARES_APPLICATION_NAME ) ); + setApplicationVersion( QStringLiteral( CALAMARES_VERSION ) ); + + QFont f = font(); + Calamares::setDefaultFontSize( f.pointSize() ); +} + +void +CalamaresApplication::init() +{ + Logger::setupLogfile(); + cDebug() << "Calamares version:" << CALAMARES_VERSION; + cDebug() << Logger::SubEntry << "Using Qt version:" << qVersion(); + cDebug() << Logger::SubEntry << "Build type:" << CMAKE_BUILD_TYPE; +#ifdef WITH_PYBIND11 + cDebug() << Logger::SubEntry << "Using PyBind11"; +#endif +#ifdef WITH_BOOST_PYTHON + cDebug() << Logger::SubEntry << "Using Boost Python"; +#endif + cDebug() << Logger::SubEntry << "Using settings:" << Calamares::Settings::instance()->path(); + cDebug() << Logger::SubEntry << "Using log file:" << Logger::logFile(); + cDebug() << Logger::SubEntry << "Languages:" << Calamares::Locale::availableLanguages(); + + if ( !Calamares::Settings::instance() ) + { + cError() << "Must create Calamares::Settings before the application."; + ::exit( 1 ); + } + initQmlPath(); + initBranding(); + + Calamares::installTranslator(); + + setQuitOnLastWindowClosed( false ); + setWindowIcon( QIcon( Calamares::Branding::instance()->imagePath( Calamares::Branding::ProductIcon ) ) ); + + cDebug() << Logger::SubEntry << "STARTUP: initSettings, initQmlPath, initBranding done"; + + initModuleManager(); //also shows main window + + cDebug() << Logger::SubEntry << "STARTUP: initModuleManager: module init started"; +} + +CalamaresApplication::~CalamaresApplication() +{ + Logger::CDebug( Logger::LOGVERBOSE ) << "Shutting down Calamares..."; + Logger::CDebug( Logger::LOGVERBOSE ) << Logger::SubEntry << "Finished shutdown."; +} + +CalamaresApplication* +CalamaresApplication::instance() +{ + return qobject_cast< CalamaresApplication* >( QApplication::instance() ); +} + +CalamaresWindow* +CalamaresApplication::mainWindow() +{ + return m_mainwindow; +} + +static QStringList +brandingFileCandidates( bool assumeBuilddir, const QString& brandingFilename ) +{ + QStringList brandingPaths; + if ( Calamares::isAppDataDirOverridden() ) + { + brandingPaths << Calamares::appDataDir().absoluteFilePath( brandingFilename ); + } + else + { + if ( assumeBuilddir ) + { + brandingPaths << ( QDir::currentPath() + QStringLiteral( "/src/" ) + brandingFilename ); + } + if ( Calamares::haveExtraDirs() ) + { + for ( auto s : Calamares::extraDataDirs() ) + { + brandingPaths << ( s + brandingFilename ); + } + } + brandingPaths << QDir( CMAKE_INSTALL_FULL_SYSCONFDIR "/calamares/" ).absoluteFilePath( brandingFilename ); + brandingPaths << Calamares::appDataDir().absoluteFilePath( brandingFilename ); + } + + return brandingPaths; +} + +void +CalamaresApplication::initQmlPath() +{ +#ifdef WITH_QML + if ( !Calamares::initQmlModulesDir() ) + { + ::exit( EXIT_FAILURE ); + } +#endif +} + +void +CalamaresApplication::initBranding() +{ + QString brandingComponentName = Calamares::Settings::instance()->brandingComponentName(); + if ( brandingComponentName.simplified().isEmpty() ) + { + cError() << "FATAL: branding component not set in settings.conf"; + ::exit( EXIT_FAILURE ); + } + + QString brandingDescriptorSubpath = QString( "branding/%1/branding.desc" ).arg( brandingComponentName ); + QStringList brandingFileCandidatesByPriority = brandingFileCandidates( isDebug(), brandingDescriptorSubpath ); + + QFileInfo brandingFile; + bool found = false; + + foreach ( const QString& path, brandingFileCandidatesByPriority ) + { + QFileInfo pathFi( path ); + if ( pathFi.exists() && pathFi.isReadable() ) + { + brandingFile = pathFi; + found = true; + break; + } + } + + if ( !found || !brandingFile.exists() || !brandingFile.isReadable() ) + { + cError() << "Cowardly refusing to continue startup without branding." + << Logger::DebugList( brandingFileCandidatesByPriority ); + if ( Calamares::isAppDataDirOverridden() ) + { + cError() << "FATAL: explicitly configured application data directory is missing" << brandingComponentName; + } + else + { + cError() << "FATAL: none of the expected branding descriptor file paths exist."; + } + ::exit( EXIT_FAILURE ); + } + + new Calamares::Branding( brandingFile.absoluteFilePath(), this, devicePixelRatio() ); +} + +void +CalamaresApplication::initModuleManager() +{ + m_moduleManager = new Calamares::ModuleManager( Calamares::Settings::instance()->modulesSearchPaths(), this ); + connect( m_moduleManager, &Calamares::ModuleManager::initDone, this, &CalamaresApplication::initView ); + m_moduleManager->init(); +} + +/** @brief centers the widget @p w on (a) screen + * + * This tries to duplicate the (deprecated) qApp->desktop()->availableGeometry() + * placement by iterating over screens and putting Calamares in the first + * one where it fits; this is *generally* the primary screen. + * + * With debugging, it would look something like this (2 screens attached, + * primary at +1080+240 because I have a very strange X setup). Before + * being mapped, the Calamares window is at +0+0 but does have a size. + * The first screen's geometry includes the offset from the origin in + * screen coordinates. + * + * Proposed window size: 1024 520 + * Window QRect(0,0 1024x520) + * Screen QRect(1080,240 2560x1440) + * Moving QPoint(1848,700) + * Screen QRect(0,0 1080x1920) + * + */ +static void +centerWindowOnScreen( QWidget* w ) +{ + QList< QScreen* > screens = qApp->screens(); + QPoint windowCenter = w->rect().center(); + QSize windowSize = w->rect().size(); + + for ( const auto* screen : screens ) + { + QSize screenSize = screen->availableGeometry().size(); + if ( ( screenSize.width() >= windowSize.width() ) && ( screenSize.height() >= windowSize.height() ) ) + { + w->move( screen->availableGeometry().center() - windowCenter ); + break; + } + } +} + +void +CalamaresApplication::initView() +{ + cDebug() << "STARTUP: initModuleManager: all modules init done"; + initJobQueue(); + cDebug() << "STARTUP: initJobQueue done"; + + m_mainwindow = new CalamaresWindow(); //also creates ViewManager + + connect( m_moduleManager, &Calamares::ModuleManager::modulesLoaded, this, &CalamaresApplication::initViewSteps ); + connect( m_moduleManager, &Calamares::ModuleManager::modulesFailed, this, &CalamaresApplication::initFailed ); + + QTimer::singleShot( 0, m_moduleManager, &Calamares::ModuleManager::loadModules ); + + if ( Calamares::Branding::instance() && Calamares::Branding::instance()->windowPlacementCentered() ) + { + centerWindowOnScreen( m_mainwindow ); + } + cDebug() << "STARTUP: CalamaresWindow created; loadModules started"; +} + +void +CalamaresApplication::initViewSteps() +{ + cDebug() << "STARTUP: loadModules for all modules done"; + m_moduleManager->checkRequirements(); + if ( Calamares::Branding::instance()->windowMaximize() ) + { + m_mainwindow->setWindowFlag( Qt::FramelessWindowHint ); + m_mainwindow->showMaximized(); + } + else + { + m_mainwindow->show(); + } + + cDebug() << "STARTUP: Window now visible and ProgressTreeView populated"; + cDebug() << Logger::SubEntry << Calamares::ViewManager::instance()->viewSteps().count() << "view steps loaded."; + Calamares::ViewManager::instance()->onInitComplete(); +} + +void +CalamaresApplication::initFailed( const QStringList& l ) +{ + cError() << "STARTUP: failed modules are" << l; + m_mainwindow->show(); +} + +void +CalamaresApplication::initJobQueue() +{ + Calamares::JobQueue* jobQueue = new Calamares::JobQueue( this ); + new Calamares::System( Calamares::Settings::instance()->doChroot(), this ); + Calamares::Branding::instance()->setGlobals( jobQueue->globalStorage() ); +} diff --git a/calamares/src/calamares/CalamaresApplication.h b/calamares/src/calamares/CalamaresApplication.h new file mode 100644 index 0000000..77cf3a0 --- /dev/null +++ b/calamares/src/calamares/CalamaresApplication.h @@ -0,0 +1,64 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARESAPPLICATION_H +#define CALAMARESAPPLICATION_H + +#include + +class CalamaresWindow; + +namespace Calamares +{ +class ModuleManager; +} // namespace Calamares + + +/** + * @brief The CalamaresApplication class extends QApplication to handle + * Calamares startup and lifetime of main components. + */ +class CalamaresApplication : public QApplication +{ + Q_OBJECT +public: + CalamaresApplication( int& argc, char* argv[] ); + ~CalamaresApplication() override; + + /** + * @brief init handles the first part of Calamares application startup. + * After the main window shows up, the latter part of the startup sequence + * (including modules loading) happens asynchronously. + */ + void init(); + static CalamaresApplication* instance(); + + /** + * @brief mainWindow returns the Calamares application main window. + */ + CalamaresWindow* mainWindow(); + +private slots: + void initView(); + void initViewSteps(); + void initFailed( const QStringList& l ); + +private: + // Initialization steps happen in this order + void initQmlPath(); + void initBranding(); + void initModuleManager(); + void initJobQueue(); + + CalamaresWindow* m_mainwindow; + Calamares::ModuleManager* m_moduleManager; +}; + +#endif // CALAMARESAPPLICATION_H diff --git a/calamares/src/calamares/CalamaresWindow.cpp b/calamares/src/calamares/CalamaresWindow.cpp new file mode 100644 index 0000000..bad0480 --- /dev/null +++ b/calamares/src/calamares/CalamaresWindow.cpp @@ -0,0 +1,552 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2018 Raul Rodrigo Segura (raurodse) + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2020 Anubhav Choudhary + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CalamaresWindow.h" + +#include "Branding.h" +#include "CalamaresConfig.h" +#include "DebugWindow.h" +#include "Settings.h" +#include "ViewManager.h" +#include "progresstree/ProgressTreeView.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Qml.h" +#include "utils/Retranslator.h" + +#include +#include +#include +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) +#include +#endif +#include +#include +#include +#ifdef WITH_QML +#include +#include +#include +#include +#endif +#include + +static QSize +desktopSize( QWidget* w ) +{ +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + return qApp->desktop()->availableGeometry( w ).size(); +#else + return w->screen()->availableGeometry().size(); +#endif +} + +static inline int +windowDimensionToPixels( const Calamares::Branding::WindowDimension& u ) +{ + if ( !u.isValid() ) + { + return 0; + } + if ( u.unit() == Calamares::Branding::WindowDimensionUnit::Pixies ) + { + return static_cast< int >( u.value() ); + } + if ( u.unit() == Calamares::Branding::WindowDimensionUnit::Fonties ) + { + return static_cast< int >( u.value() * Calamares::defaultFontHeight() ); + } + return 0; +} + +/** @brief Expected orientation of the panels, based on their side + * + * Panels on the left and right are expected to be "vertical" style, + * top and bottom should be "horizontal bars". This function maps + * the sides to expected orientation. + */ +static inline Qt::Orientation +orientation( const Calamares::Branding::PanelSide s ) +{ + using Side = Calamares::Branding::PanelSide; + return ( s == Side::Left || s == Side::Right ) ? Qt::Orientation::Vertical : Qt::Orientation::Horizontal; +} + +/** @brief Get a button-sized icon. */ +static inline QPixmap +getButtonIcon( const QString& name ) +{ + return Calamares::Branding::instance()->image( name, QSize( 22, 22 ) ); +} + +static inline void +setButtonIcon( QPushButton* button, const QString& name ) +{ + auto icon = getButtonIcon( name ); + if ( button && !icon.isNull() ) + { + button->setIcon( icon ); + } +} + +static QWidget* +getWidgetSidebar( Calamares::DebugWindowManager* debug, + Calamares::ViewManager* viewManager, + QWidget* parent, + Qt::Orientation, + int desiredWidth ) +{ + const Calamares::Branding* const branding = Calamares::Branding::instance(); + + QWidget* sideBox = new QWidget( parent ); + sideBox->setObjectName( "sidebarApp" ); + + QBoxLayout* sideLayout = new QVBoxLayout; + sideBox->setLayout( sideLayout ); + // Set this attribute into qss file + sideBox->setFixedWidth( desiredWidth ); + sideBox->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); + + QHBoxLayout* logoLayout = new QHBoxLayout; + sideLayout->addLayout( logoLayout ); + logoLayout->addStretch(); + QLabel* logoLabel = new QLabel( sideBox ); + logoLabel->setObjectName( "logoApp" ); + //Define all values into qss file + { + QPalette plt = sideBox->palette(); + sideBox->setAutoFillBackground( true ); + plt.setColor( sideBox->backgroundRole(), branding->styleString( Calamares::Branding::SidebarBackground ) ); + plt.setColor( sideBox->foregroundRole(), branding->styleString( Calamares::Branding::SidebarText ) ); + sideBox->setPalette( plt ); + logoLabel->setPalette( plt ); + } + logoLabel->setAlignment( Qt::AlignCenter ); + logoLabel->setFixedSize( 80, 80 ); + logoLabel->setPixmap( branding->image( Calamares::Branding::ProductLogo, logoLabel->size() ) ); + logoLayout->addWidget( logoLabel ); + logoLayout->addStretch(); + + ProgressTreeView* tv = new ProgressTreeView( sideBox ); + tv->setModel( viewManager ); + tv->setFocusPolicy( Qt::NoFocus ); + sideLayout->addWidget( tv ); + + QHBoxLayout* extraButtons = new QHBoxLayout; + sideLayout->addLayout( extraButtons ); + + const int defaultFontHeight = Calamares::defaultFontHeight(); + + if ( /* About-Calamares Button enabled */ true ) + { + QPushButton* aboutDialog = new QPushButton; + aboutDialog->setObjectName( "aboutButton" ); + aboutDialog->setIcon( Calamares::defaultPixmap( + Calamares::Information, Calamares::Original, 2 * QSize( defaultFontHeight, defaultFontHeight ) ) ); + CALAMARES_RETRANSLATE_FOR( + aboutDialog, aboutDialog->setText( QCoreApplication::translate( "calamares-sidebar", "About", "@button" ) ); + aboutDialog->setToolTip( + QCoreApplication::translate( "calamares-sidebar", "Show information about Calamares", "@tooltip" ) ); ); + extraButtons->addWidget( aboutDialog ); + aboutDialog->setFlat( true ); + aboutDialog->setCheckable( true ); + QObject::connect( aboutDialog, &QPushButton::clicked, debug, &Calamares::DebugWindowManager::about ); + } + if ( debug && debug->enabled() ) + { + QPushButton* debugWindowBtn = new QPushButton; + debugWindowBtn->setObjectName( "debugButton" ); + debugWindowBtn->setIcon( Calamares::defaultPixmap( + Calamares::Bugs, Calamares::Original, 2 * QSize( defaultFontHeight, defaultFontHeight ) ) ); + CALAMARES_RETRANSLATE_FOR( + debugWindowBtn, + debugWindowBtn->setText( QCoreApplication::translate( "calamares-sidebar", "Debug", "@button" ) ); + debugWindowBtn->setToolTip( + QCoreApplication::translate( "calamares-sidebar", "Show debug information", "@tooltip" ) ); ); + extraButtons->addWidget( debugWindowBtn ); + debugWindowBtn->setFlat( true ); + debugWindowBtn->setCheckable( true ); + QObject::connect( debugWindowBtn, &QPushButton::clicked, debug, &Calamares::DebugWindowManager::show ); + QObject::connect( + debug, &Calamares::DebugWindowManager::visibleChanged, debugWindowBtn, &QPushButton::setChecked ); + } + + Calamares::unmarginLayout( sideLayout ); + return sideBox; +} + +static QWidget* +getWidgetNavigation( Calamares::DebugWindowManager*, + Calamares::ViewManager* viewManager, + QWidget* parent, + Qt::Orientation, + int ) +{ + QWidget* navigation = new QWidget( parent ); + QBoxLayout* bottomLayout = new QHBoxLayout; + bottomLayout->addStretch(); + + // Create buttons and sets an initial icon; the icons may change + { + auto* back = new QPushButton( + getButtonIcon( QStringLiteral( "go-previous" ) ), + QCoreApplication::translate( CalamaresWindow::staticMetaObject.className(), "&Back", "@button" ), + navigation ); + back->setObjectName( "view-button-back" ); + back->setEnabled( viewManager->backEnabled() ); + QObject::connect( back, &QPushButton::clicked, viewManager, &Calamares::ViewManager::back ); + QObject::connect( viewManager, &Calamares::ViewManager::backEnabledChanged, back, &QPushButton::setEnabled ); + QObject::connect( viewManager, &Calamares::ViewManager::backLabelChanged, back, &QPushButton::setText ); + QObject::connect( + viewManager, &Calamares::ViewManager::backIconChanged, [ = ]( QString n ) { setButtonIcon( back, n ); } ); + QObject::connect( + viewManager, &Calamares::ViewManager::backAndNextVisibleChanged, back, &QPushButton::setVisible ); + bottomLayout->addWidget( back ); + } + { + auto* next = new QPushButton( + getButtonIcon( QStringLiteral( "go-next" ) ), + QCoreApplication::translate( CalamaresWindow::staticMetaObject.className(), "&Next", "@button" ), + navigation ); + next->setObjectName( "view-button-next" ); + next->setEnabled( viewManager->nextEnabled() ); + QObject::connect( next, &QPushButton::clicked, viewManager, &Calamares::ViewManager::next ); + QObject::connect( viewManager, &Calamares::ViewManager::nextEnabledChanged, next, &QPushButton::setEnabled ); + QObject::connect( viewManager, &Calamares::ViewManager::nextLabelChanged, next, &QPushButton::setText ); + QObject::connect( + viewManager, &Calamares::ViewManager::nextIconChanged, [ = ]( QString n ) { setButtonIcon( next, n ); } ); + QObject::connect( + viewManager, &Calamares::ViewManager::backAndNextVisibleChanged, next, &QPushButton::setVisible ); + bottomLayout->addWidget( next ); + } + bottomLayout->addSpacing( 12 ); + { + auto* quit = new QPushButton( + getButtonIcon( QStringLiteral( "dialog-cancel" ) ), + QCoreApplication::translate( CalamaresWindow::staticMetaObject.className(), "&Cancel", "@button" ), + navigation ); + quit->setObjectName( "view-button-cancel" ); + QObject::connect( quit, &QPushButton::clicked, viewManager, &Calamares::ViewManager::quit ); + QObject::connect( viewManager, &Calamares::ViewManager::quitEnabledChanged, quit, &QPushButton::setEnabled ); + QObject::connect( viewManager, &Calamares::ViewManager::quitLabelChanged, quit, &QPushButton::setText ); + QObject::connect( + viewManager, &Calamares::ViewManager::quitIconChanged, [ = ]( QString n ) { setButtonIcon( quit, n ); } ); + QObject::connect( viewManager, &Calamares::ViewManager::quitTooltipChanged, quit, &QPushButton::setToolTip ); + QObject::connect( viewManager, &Calamares::ViewManager::quitVisibleChanged, quit, &QPushButton::setVisible ); + bottomLayout->addWidget( quit ); + } + + bottomLayout->setContentsMargins( 0, 0, 6, 6 ); + navigation->setLayout( bottomLayout ); + return navigation; +} + +#ifdef WITH_QML + +static inline void +setDimension( QQuickWidget* w, Qt::Orientation o, int desiredWidth ) +{ + w->setSizePolicy( o == Qt::Orientation::Vertical ? QSizePolicy::MinimumExpanding : QSizePolicy::Expanding, + o == Qt::Orientation::Horizontal ? QSizePolicy::MinimumExpanding : QSizePolicy::Expanding ); + if ( o == Qt::Orientation::Vertical ) + { + w->setFixedWidth( desiredWidth ); + } + else + { + // If the QML itself sets a height, use that, otherwise go to 48 pixels + // which seems to match what the widget navigation would use for height + // (with *my* specific screen, style, etc. so YMMV). + // + // Bound between (16, 64) with a default of 48. + qreal minimumHeight = qBound( qreal( 16 ), w->rootObject() ? w->rootObject()->height() : 48, qreal( 64 ) ); + w->setMinimumHeight( int( minimumHeight ) ); + w->setFixedHeight( int( minimumHeight ) ); + } + w->setResizeMode( QQuickWidget::SizeRootObjectToView ); +} + +static QWidget* +getQmlSidebar( Calamares::DebugWindowManager* debug, + Calamares::ViewManager*, + QWidget* parent, + Qt::Orientation o, + int desiredWidth ) +{ + Calamares::registerQmlModels(); + QQuickWidget* w = new QQuickWidget( parent ); + if ( debug ) + { + w->engine()->rootContext()->setContextProperty( "debug", debug ); + } + + w->setSource( + QUrl( Calamares::searchQmlFile( Calamares::QmlSearch::Both, QStringLiteral( "calamares-sidebar" ) ) ) ); + setDimension( w, o, desiredWidth ); + return w; +} + +static QWidget* +getQmlNavigation( Calamares::DebugWindowManager* debug, + Calamares::ViewManager*, + QWidget* parent, + Qt::Orientation o, + int desiredWidth ) +{ + Calamares::registerQmlModels(); + QQuickWidget* w = new QQuickWidget( parent ); + if ( debug ) + { + w->engine()->rootContext()->setContextProperty( "debug", debug ); + } + w->setSource( + QUrl( Calamares::searchQmlFile( Calamares::QmlSearch::Both, QStringLiteral( "calamares-navigation" ) ) ) ); + setDimension( w, o, desiredWidth ); + return w; +} +#else +// Bogus to keep the linker happy +// +// Calls to flavoredWidget() still refer to these *names* +// even if they are subsequently not used. +static QWidget* +getQmlSidebar( Calamares::DebugWindowManager*, + Calamares::ViewManager*, + QWidget* parent, + Qt::Orientation, + int desiredWidth ) +{ + return nullptr; +} +static QWidget* +getQmlNavigation( Calamares::DebugWindowManager*, + Calamares::ViewManager*, + QWidget* parent, + Qt::Orientation, + int desiredWidth ) +{ + return nullptr; +} +#endif + +/**@brief Picks one of two methods to call + * + * Calls method (member function) @p widget or @p qml with arguments @p a + * on the given window, based on the flavor. + */ +template < typename widgetMaker, typename... args > +QWidget* +flavoredWidget( Calamares::Branding::PanelFlavor flavor, + Qt::Orientation o, + Calamares::DebugWindowManager* w, + QWidget* parent, + widgetMaker widget, + widgetMaker qml, // Only if WITH_QML is on + args... a ) +{ +#ifndef WITH_QML + Q_UNUSED( qml ) +#endif + auto* viewManager = Calamares::ViewManager::instance(); + switch ( flavor ) + { + case Calamares::Branding::PanelFlavor::Widget: + return widget( w, viewManager, parent, o, a... ); +#ifdef WITH_QML + case Calamares::Branding::PanelFlavor::Qml: + return qml( w, viewManager, parent, o, a... ); +#endif + case Calamares::Branding::PanelFlavor::None: + return nullptr; + } + __builtin_unreachable(); +} + +/** @brief Adds widgets to @p layout if they belong on this @p side + */ +static inline void +insertIf( QBoxLayout* layout, + Calamares::Branding::PanelSide side, + QWidget* first, + Calamares::Branding::PanelSide firstSide ) +{ + if ( first && side == firstSide ) + { + layout->addWidget( first ); + } +} + +CalamaresWindow::CalamaresWindow( QWidget* parent ) + : QWidget( parent ) + , m_debugManager( new Calamares::DebugWindowManager( this ) ) + , m_viewManager( nullptr ) +{ + installEventFilter( Calamares::Retranslator::instance() ); + + // If we can never cancel, don't show the window-close button + if ( Calamares::Settings::instance()->disableCancel() ) + { + setWindowFlag( Qt::WindowCloseButtonHint, false ); + } + + // %1 is the distribution name + CALAMARES_RETRANSLATE( const auto* branding = Calamares::Branding::instance(); + setWindowTitle( Calamares::Settings::instance()->isSetupMode() + ? tr( "%1 Setup Program" ).arg( branding->productName() ) + : tr( "%1 Installer" ).arg( branding->productName() ) ); ); + + const Calamares::Branding* const branding = Calamares::Branding::instance(); + using ImageEntry = Calamares::Branding::ImageEntry; + + using Calamares::windowMinimumHeight; + using Calamares::windowMinimumWidth; + using Calamares::windowPreferredHeight; + using Calamares::windowPreferredWidth; + + using PanelSide = Calamares::Branding::PanelSide; + + // Needs to match what's checked in DebugWindow + this->setObjectName( "mainApp" ); + + QSize availableSize = desktopSize( this ); + QSize minimumSize( qBound( windowMinimumWidth, availableSize.width(), windowPreferredWidth ), + qBound( windowMinimumHeight, availableSize.height(), windowPreferredHeight ) ); + setMinimumSize( minimumSize ); + + cDebug() << "Available desktop" << availableSize << "minimum size" << minimumSize; + + auto brandingSizes = branding->windowSize(); + + int w = qBound( minimumSize.width(), windowDimensionToPixels( brandingSizes.first ), availableSize.width() ); + int h = qBound( minimumSize.height(), windowDimensionToPixels( brandingSizes.second ), availableSize.height() ); + + cDebug() << Logger::SubEntry << "Proposed window size:" << w << h; + resize( w, h ); + + QWidget* baseWidget = this; + if ( !( branding->imagePath( ImageEntry::ProductWallpaper ).isEmpty() ) ) + { + QWidget* label = new QWidget( this ); + QVBoxLayout* l = new QVBoxLayout; + Calamares::unmarginLayout( l ); + l->addWidget( label ); + setLayout( l ); + label->setObjectName( "backgroundWidget" ); + label->setStyleSheet( + QStringLiteral( "#backgroundWidget { background-image: url(%1); background-repeat: repeat-xy; }" ) + .arg( branding->imagePath( ImageEntry::ProductWallpaper ) ) ); + + baseWidget = label; + } + + m_viewManager = Calamares::ViewManager::instance( baseWidget ); + if ( branding->windowExpands() ) + { + connect( m_viewManager, &Calamares::ViewManager::ensureSize, this, &CalamaresWindow::ensureSize ); + } + // NOTE: Although the ViewManager has a signal cancelEnabled() that + // signals when the state of the cancel button changes (in + // particular, to disable cancel during the exec phase), + // we don't connect to it here. Changing the window flag + // for the close button causes uncomfortable window flashing + // and requires an extra show() (at least with KWin/X11) which + // is too annoying. Instead, leave it up to ignoring-the-quit- + // event, which is also the ViewManager's responsibility. + + QBoxLayout* mainLayout = new QHBoxLayout; + QBoxLayout* contentsLayout = new QVBoxLayout; + contentsLayout->setSpacing( 0 ); + + QWidget* sideBox + = flavoredWidget( branding->sidebarFlavor(), + ::orientation( branding->sidebarSide() ), + m_debugManager, + baseWidget, + ::getWidgetSidebar, + ::getQmlSidebar, + qBound( 100, Calamares::defaultFontHeight() * 12, w < windowPreferredWidth ? 100 : 190 ) ); + QWidget* navigation = flavoredWidget( branding->navigationFlavor(), + ::orientation( branding->navigationSide() ), + m_debugManager, + baseWidget, + ::getWidgetNavigation, + ::getQmlNavigation, + 64 ); + + // Build up the contentsLayout (a VBox) top-to-bottom + // .. note that the bottom is mirrored wrt. the top + insertIf( contentsLayout, PanelSide::Top, sideBox, branding->sidebarSide() ); + insertIf( contentsLayout, PanelSide::Top, navigation, branding->navigationSide() ); + contentsLayout->addWidget( m_viewManager->centralWidget() ); + insertIf( contentsLayout, PanelSide::Bottom, navigation, branding->navigationSide() ); + insertIf( contentsLayout, PanelSide::Bottom, sideBox, branding->sidebarSide() ); + + // .. and then the mainLayout left-to-right + insertIf( mainLayout, PanelSide::Left, sideBox, branding->sidebarSide() ); + insertIf( mainLayout, PanelSide::Left, navigation, branding->navigationSide() ); + mainLayout->addLayout( contentsLayout ); + insertIf( mainLayout, PanelSide::Right, navigation, branding->navigationSide() ); + insertIf( mainLayout, PanelSide::Right, sideBox, branding->sidebarSide() ); + + // layout->count() returns number of things in it; above we have put + // at **least** the central widget, which comes from the view manager, + // both vertically and horizontally -- so if there's a panel along + // either axis, the count in that axis will be > 1. + m_viewManager->setPanelSides( + ( contentsLayout->count() > 1 ? Qt::Orientations( Qt::Horizontal ) : Qt::Orientations() ) + | ( mainLayout->count() > 1 ? Qt::Orientations( Qt::Vertical ) : Qt::Orientations() ) ); + + Calamares::unmarginLayout( mainLayout ); + Calamares::unmarginLayout( contentsLayout ); + baseWidget->setLayout( mainLayout ); + setStyleSheet( Calamares::Branding::instance()->stylesheet() ); +} + +void +CalamaresWindow::ensureSize( QSize size ) +{ + auto mainGeometry = this->geometry(); + QSize availableSize = desktopSize( this ); + + // We only care about vertical sizes that are big enough + int embiggenment = qMax( 0, size.height() - m_viewManager->centralWidget()->size().height() ); + if ( embiggenment < 6 ) + { + return; + } + + auto h = qBound( 0, mainGeometry.height() + embiggenment, availableSize.height() ); + auto w = this->size().width(); + + resize( w, h ); +} + +void +CalamaresWindow::closeEvent( QCloseEvent* event ) +{ + if ( m_viewManager ) + { + m_viewManager->quit(); + // If it didn't actually exit, eat the event to ignore close + event->ignore(); + } + else + { + event->accept(); +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + QApplication::quit(); +#else + QApplication::exit( EXIT_SUCCESS ); +#endif + } +} diff --git a/calamares/src/calamares/CalamaresWindow.h b/calamares/src/calamares/CalamaresWindow.h new file mode 100644 index 0000000..f5dd7fe --- /dev/null +++ b/calamares/src/calamares/CalamaresWindow.h @@ -0,0 +1,50 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARESWINDOW_H +#define CALAMARESWINDOW_H + +#include + +#include + +namespace Calamares +{ +class DebugWindowManager; +class ViewManager; +} // namespace Calamares + +/** + * @brief The CalamaresWindow class represents the main window of the Calamares UI. + */ +class CalamaresWindow : public QWidget +{ + Q_OBJECT +public: + CalamaresWindow( QWidget* parent = nullptr ); + ~CalamaresWindow() override {} + +public Q_SLOTS: + /** + * This asks the main window to grow to accomodate @p size pixels, to accomodate + * larger-than-expected window contents. The enlargement may be silently + * ignored. + */ + void ensureSize( QSize size ); + +protected: + virtual void closeEvent( QCloseEvent* e ) override; + +private: + Calamares::DebugWindowManager* m_debugManager = nullptr; + Calamares::ViewManager* m_viewManager = nullptr; +}; + +#endif // CALAMARESWINDOW_H diff --git a/calamares/src/calamares/DebugWindow.cpp b/calamares/src/calamares/DebugWindow.cpp new file mode 100644 index 0000000..51b3fca --- /dev/null +++ b/calamares/src/calamares/DebugWindow.cpp @@ -0,0 +1,257 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "DebugWindow.h" +#include "ui_DebugWindow.h" + +#include "Branding.h" +#include "CalamaresAbout.h" +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "Job.h" +#include "JobQueue.h" +#include "Settings.h" +#include "VariantModel.h" +#include "modulesystem/Module.h" +#include "modulesystem/ModuleManager.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Paste.h" +#include "utils/Retranslator.h" +#include "widgets/TranslationFix.h" + +#include +#include +#include +#include +#include + +#include +#include + +/** + * @brief crash makes Calamares crash immediately. + */ +static void +crash() +{ + kill( getpid(), SIGTRAP ); +} + +/// @brief Print out the widget tree (names) in indented form. +static void +dumpWidgetTree( QDebug& deb, const QWidget* widget, int depth ) +{ + if ( !widget ) + { + return; + } + + deb << Logger::Continuation; + for ( int i = 0; i < depth; ++i ) + { + deb << ' '; + } + deb << widget->metaObject()->className() << widget->objectName(); + + for ( const auto* w : widget->findChildren< QWidget* >( QString(), Qt::FindDirectChildrenOnly ) ) + { + dumpWidgetTree( deb, w, depth + 1 ); + } +} + +namespace Calamares +{ + +DebugWindow::DebugWindow() + : QWidget( nullptr ) + , m_ui( new Ui::DebugWindow ) + , m_globals( JobQueue::instance()->globalStorage()->data() ) + , m_globals_model( std::make_unique< VariantModel >( &m_globals ) ) + , m_module_model( std::make_unique< VariantModel >( &m_module ) ) +{ + GlobalStorage* gs = JobQueue::instance()->globalStorage(); + + m_ui->setupUi( this ); + + m_ui->globalStorageView->setModel( m_globals_model.get() ); + m_ui->globalStorageView->expandAll(); + + // Do above when the GS changes, too + connect( gs, + &GlobalStorage::changed, + this, + [ = ] + { + m_globals = JobQueue::instance()->globalStorage()->data(); + m_globals_model->reload(); + m_ui->globalStorageView->expandAll(); + } ); + + // JobQueue page + m_ui->jobQueueText->setReadOnly( true ); + connect( JobQueue::instance(), + &JobQueue::queueChanged, + this, + [ this ]( const QStringList& jobs ) { m_ui->jobQueueText->setText( jobs.join( '\n' ) ); } ); + + // Modules page + QStringList modulesKeys; + for ( const auto& m : ModuleManager::instance()->loadedInstanceKeys() ) + { + modulesKeys << m.toString(); + } + + QStringListModel* modulesModel = new QStringListModel( modulesKeys ); + m_ui->modulesListView->setModel( modulesModel ); + m_ui->modulesListView->setSelectionMode( QAbstractItemView::SingleSelection ); + + m_ui->moduleConfigView->setModel( m_module_model.get() ); + + connect( m_ui->modulesListView->selectionModel(), + &QItemSelectionModel::selectionChanged, + this, + [ this ] + { + QString moduleName = m_ui->modulesListView->currentIndex().data().toString(); + Module* module + = ModuleManager::instance()->moduleInstance( ModuleSystem::InstanceKey::fromString( moduleName ) ); + if ( module ) + { + m_module = module->configurationMap(); + m_module_model->reload(); + m_ui->moduleConfigView->expandAll(); + m_ui->moduleTypeLabel->setText( module->typeString() ); + m_ui->moduleInterfaceLabel->setText( module->interfaceString() ); + } + } ); + + // Tools page + connect( m_ui->crashButton, &QPushButton::clicked, this, [] { ::crash(); } ); + connect( m_ui->reloadStylesheetButton, + &QPushButton::clicked, + []() + { + for ( auto* w : qApp->topLevelWidgets() ) + { + // Needs to match what's set in CalamaresWindow + if ( w->objectName() == QStringLiteral( "mainApp" ) ) + { + w->setStyleSheet( Calamares::Branding::instance()->stylesheet() ); + } + } + } ); + connect( m_ui->widgetTreeButton, + &QPushButton::clicked, + []() + { + for ( auto* w : qApp->topLevelWidgets() ) + { + Logger::CDebug deb; + dumpWidgetTree( deb, w, 0 ); + } + } ); + + // Send Log button only if it would be useful + m_ui->sendLogButton->setVisible( Calamares::Paste::isEnabled() ); + connect( m_ui->sendLogButton, &QPushButton::clicked, [ this ]() { Calamares::Paste::doLogUploadUI( this ); } ); + + CALAMARES_RETRANSLATE( m_ui->retranslateUi( this ); setWindowTitle( tr( "Debug Information", "@title" ) ); ); +} + +void +DebugWindow::closeEvent( QCloseEvent* e ) +{ + Q_UNUSED( e ) + emit closed(); +} + +DebugWindowManager::DebugWindowManager( QObject* parent ) + : QObject( parent ) +{ +} + +bool +DebugWindowManager::enabled() const +{ + const auto* s = Settings::instance(); + return ( Logger::logLevel() >= Logger::LOGVERBOSE ) || ( s ? s->debugMode() : false ); +} + +void +DebugWindowManager::show( bool visible ) +{ + if ( !enabled() ) + { + visible = false; + } + if ( m_visible == visible ) + { + return; + } + + if ( visible ) + { + m_debugWindow = new Calamares::DebugWindow(); + m_debugWindow->show(); + connect( m_debugWindow.data(), + &Calamares::DebugWindow::closed, + this, + [ = ]() + { + m_debugWindow->deleteLater(); + m_visible = false; + emit visibleChanged( false ); + } ); + m_visible = true; + emit visibleChanged( true ); + } + else + { + if ( m_debugWindow ) + { + m_debugWindow->deleteLater(); + } + m_visible = false; + emit visibleChanged( false ); + } +} + +void +DebugWindowManager::toggle() +{ + show( !m_visible ); +} + +void +DebugWindowManager::about() +{ + QString title = Calamares::Settings::instance()->isSetupMode() + ? QCoreApplication::translate( "WelcomePage", "About %1 Setup", "@title" ) + : QCoreApplication::translate( "WelcomePage", "About %1 Installer", "@title" ); + QMessageBox mb( QMessageBox::Information, + title.arg( CALAMARES_APPLICATION_NAME ), + Calamares::aboutString().arg( Calamares::Branding::instance()->versionedName() ), + QMessageBox::Ok, + nullptr ); + Calamares::fixButtonLabels( &mb ); + mb.setIconPixmap( + Calamares::defaultPixmap( Calamares::Squid, + Calamares::Original, + QSize( Calamares::defaultFontHeight() * 6, Calamares::defaultFontHeight() * 6 ) ) ); + QGridLayout* layout = reinterpret_cast< QGridLayout* >( mb.layout() ); + if ( layout ) + { + layout->setColumnMinimumWidth( 2, Calamares::defaultFontHeight() * 24 ); + } + mb.exec(); +} + +} // namespace Calamares diff --git a/calamares/src/calamares/DebugWindow.h b/calamares/src/calamares/DebugWindow.h new file mode 100644 index 0000000..83bfb08 --- /dev/null +++ b/calamares/src/calamares/DebugWindow.h @@ -0,0 +1,96 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_DEBUGWINDOW_H +#define CALAMARES_DEBUGWINDOW_H + +#include "VariantModel.h" + +#include +#include +#include + +#include + +namespace Calamares +{ + +// From the .ui file +namespace Ui +{ +class DebugWindow; +} // namespace Ui + +class DebugWindow : public QWidget +{ + Q_OBJECT + +public: + explicit DebugWindow(); + +signals: + void closed(); + +protected: + void closeEvent( QCloseEvent* e ) override; + +private: + Ui::DebugWindow* m_ui; + QVariant m_globals; + QVariant m_module; + std::unique_ptr< VariantModel > m_globals_model; + std::unique_ptr< VariantModel > m_module_model; +}; + +/** @brief Manager for meta-windows (Debug and About windows) + * + * Only one DebugWindow is expected to be around. This class manages + * (exactly one) DebugWindow and can create and destroy it as needed. + * It is available to the Calamares panels as object `DebugWindow`. + * + * The about() method shows a modal pop-up about Calamares. + */ +class DebugWindowManager : public QObject +{ + Q_OBJECT + + /// @brief Proxy to Settings::debugMode() default @c false + Q_PROPERTY( bool enabled READ enabled CONSTANT FINAL ) + + /** @brief Is the debug window visible? + * + * Writing @c true to this **may** make the debug window visible to + * the user; only if debugMode() is on. + */ + Q_PROPERTY( bool visible READ visible WRITE show NOTIFY visibleChanged ) + +public: + DebugWindowManager( QObject* parent = nullptr ); + virtual ~DebugWindowManager() override = default; + +public Q_SLOTS: + bool enabled() const; + bool visible() const { return m_visible; } + void show( bool visible ); + void toggle(); + + void about(); + +signals: + void visibleChanged( bool visible ); + +private: + QPointer< DebugWindow > m_debugWindow; + bool m_visible = false; +}; + + +} // namespace Calamares +#endif diff --git a/calamares/src/calamares/DebugWindow.ui b/calamares/src/calamares/DebugWindow.ui new file mode 100644 index 0000000..16cc4a4 --- /dev/null +++ b/calamares/src/calamares/DebugWindow.ui @@ -0,0 +1,157 @@ + + + +SPDX-FileCopyrightText: 2015 Teo Mrnjavac <teo@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + Calamares::DebugWindow + + + + 0 + 0 + 962 + 651 + + + + Form + + + + + + 0 + + + + GlobalStorage + + + + + + + + + + JobQueue + + + + + + + + + + Modules + + + + + + + + + + + + + Type: + + + + + + + none + + + + + + + Interface: + + + + + + + none + + + + + + + + + + + + + + + + + + + + Crashes Calamares, so that Dr. Konqi can look at it. + + + Crash now + + + + + + + + + + Reloads the stylesheet from the branding directory. + + + Reload Stylesheet + + + + + + + + + + Displays the tree of widget names in the log (for stylesheet debugging). + + + Widget Tree + + + + + + + + + + Uploads the session log to the configured pastebin. + + + Send Session Log + + + + + + + + + + + + + diff --git a/calamares/src/calamares/VariantModel.cpp b/calamares/src/calamares/VariantModel.cpp new file mode 100644 index 0000000..da4f556 --- /dev/null +++ b/calamares/src/calamares/VariantModel.cpp @@ -0,0 +1,285 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "VariantModel.h" + +#include "compat/Variant.h" + +static bool +isMapLike( const QVariant& item ) +{ + return item.canConvert< QVariantMap >(); +} + +static bool +isListLike( const QVariant& item ) +{ + return item.canConvert< QVariantList >() && !( Calamares::typeOf( item ) == Calamares::StringVariantType ); +} + +static void +overallLength( const QVariant& item, quintptr& c, quintptr parent, VariantModel::IndexVector* skiplist ) +{ + if ( skiplist ) + { + skiplist->append( parent ); + } + + parent = c++; + if ( isMapLike( item ) ) + { + for ( const auto& subitem : item.toMap() ) + { + overallLength( subitem, c, parent, skiplist ); + } + } + else if ( isListLike( item ) ) + { + for ( const auto& subitem : item.toList() ) + { + overallLength( subitem, c, parent, skiplist ); + } + } +} + +static quintptr +findNth( const VariantModel::IndexVector& skiplist, quintptr value, int n ) +{ + constexpr const quintptr invalid_index = static_cast< quintptr >( -1 ); + + if ( n < 0 ) + { + return invalid_index; + } + + int index = static_cast< int >( value ); + while ( ( n >= 0 ) && ( index < skiplist.count() ) ) + { + if ( skiplist[ index ] == value ) + { + if ( --n < 0 ) + { + // It's bigger than 0 + return static_cast< quintptr >( index ); + } + } + index++; + } + return invalid_index; +} + + +VariantModel::VariantModel( const QVariant* p ) + : m_p( p ) +{ + reload(); +} + +VariantModel::~VariantModel() {} + +void +VariantModel::reload() +{ + constexpr const quintptr invalid_index = static_cast< quintptr >( -1 ); + + quintptr x = 0; + m_rows.clear(); // Start over + if ( m_rows.capacity() < 64 ) + { + m_rows.reserve( 64 ); // Start reasonably-sized + } + overallLength( *m_p, x, invalid_index, &m_rows ); +} + +int +VariantModel::columnCount( const QModelIndex& ) const +{ + return 2; +} + +int +VariantModel::rowCount( const QModelIndex& index ) const +{ + quintptr p = index.isValid() ? index.internalId() : 0; + return m_rows.count( p ); +} + +QModelIndex +VariantModel::index( int row, int column, const QModelIndex& parent ) const +{ + quintptr p = 0; + + if ( parent.isValid() ) + { + if ( inRange( parent ) ) + { + p = parent.internalId(); + } + } + + return createIndex( row, column, findNth( m_rows, p, row ) ); +} + +static inline quintptr +deref( const VariantModel::IndexVector& v, quintptr i ) +{ + return v[ static_cast< int >( i ) ]; +} + +QModelIndex +VariantModel::parent( const QModelIndex& index ) const +{ + if ( !index.isValid() || !inRange( index ) ) + { + return QModelIndex(); + } + + quintptr p = deref( m_rows, index.internalId() ); + if ( p == 0 ) + { + return QModelIndex(); + } + + if ( !inRange( p ) ) + { + return QModelIndex(); + } + quintptr p_pid = deref( m_rows, p ); + int row = 0; + for ( int i = static_cast< int >( p_pid ); i < static_cast< int >( p ); ++i ) + { + if ( m_rows[ i ] == p_pid ) + { + row++; + } + } + + return createIndex( row, index.column(), p ); +} + +QVariant +VariantModel::data( const QModelIndex& index, int role ) const +{ + if ( role != Qt::DisplayRole ) + { + return QVariant(); + } + + if ( !index.isValid() ) + { + return QVariant(); + } + + if ( ( index.column() < 0 ) || ( index.column() > 1 ) ) + { + return QVariant(); + } + + if ( !inRange( index ) ) + { + return QVariant(); + } + + const QVariant thing = underlying( parent( index ) ); + + if ( !thing.isValid() ) + { + return QVariant(); + } + + if ( isMapLike( thing ) ) + { + QVariantMap the_map = thing.toMap(); + const auto key = the_map.keys().at( index.row() ); + if ( index.column() == 0 ) + { + return key; + } + else + { + return the_map[ key ]; + } + } + else if ( isListLike( thing ) ) + { + if ( index.column() == 0 ) + { + return index.row(); + } + else + { + QVariantList the_list = thing.toList(); + return the_list.at( index.row() ); + } + } + else + { + if ( index.column() == 0 ) + { + return QVariant(); + } + else + { + return thing; + } + } +} + +QVariant +VariantModel::headerData( int section, Qt::Orientation orientation, int role ) const +{ + if ( role != Qt::DisplayRole ) + { + return QVariant(); + } + + if ( orientation == Qt::Horizontal ) + { + if ( section == 0 ) + { + return tr( "Key", "Column header for key/value" ); + } + else if ( section == 1 ) + { + return tr( "Value", "Column header for key/value" ); + } + else + { + return QVariant(); + } + } + else + { + return QVariant(); + } +} + +const QVariant +VariantModel::underlying( const QModelIndex& index ) const +{ + if ( !index.isValid() ) + { + return *m_p; + } + + const auto& thing = underlying( parent( index ) ); + if ( isMapLike( thing ) ) + { + const auto& the_map = thing.toMap(); + return the_map[ the_map.keys()[ index.row() ] ]; + } + else if ( isListLike( thing ) ) + { + return thing.toList()[ index.row() ]; + } + else + { + return thing; + } +} diff --git a/calamares/src/calamares/VariantModel.h b/calamares/src/calamares/VariantModel.h new file mode 100644 index 0000000..9d33231 --- /dev/null +++ b/calamares/src/calamares/VariantModel.h @@ -0,0 +1,104 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef VARIANTMODEL_H +#define VARIANTMODEL_H + +#include +#include +#include + +/** @brief A model that operates directly on a QVariant + * + * A VariantModel operates directly on an underlying + * QVariant, treating QVariantMap and QVariantList as + * nodes with multiple children. In general, putting + * a QVariantMap into a QVariant and passing that into + * the model will get you a tree-like model of the + * VariantMap's data structure. + * + * Take care of object lifetimes and that the underlying + * QVariant does not change during use. If the QVariant + * **does** change, call reload() to re-build the internal + * representation of the tree. + */ +class VariantModel : public QAbstractItemModel +{ + Q_OBJECT +public: + /** @brief Auxiliary data + * + * The nodes of the tree are enumerated into a vector + * (of length equal to the number of nodes in the tree + 1) + * which are used to do index and parent calculations. + */ + using IndexVector = QVector< quintptr >; + + /** @brief Constructor + * + * The QVariant's lifetime is **not** affected by the model, + * so take care that the QVariant lives at least as long as + * the model). Also, don't change the QVariant underneath the model. + */ + VariantModel( const QVariant* p ); + + ~VariantModel() override; + + /** @brief Re-build the internal tree + * + * Call this when the underlying variant is changed, which + * might impact how the tree is laid out. + */ + void reload(); + + int columnCount( const QModelIndex& index ) const override; + int rowCount( const QModelIndex& index ) const override; + + QModelIndex index( int row, int column, const QModelIndex& parent ) const override; + QModelIndex parent( const QModelIndex& index ) const override; + QVariant data( const QModelIndex& index, int role ) const override; + QVariant headerData( int section, Qt::Orientation orientation, int role ) const override; + +private: + const QVariant* const m_p; + + /** @brief Tree representation of the variant. + * + * At index 0 in the vector , we store -1 to indicate the root. + * + * Then we enumerate all the elements in the tree (by traversing + * the variant and using QVariantMap and QVariantList as having + * children, and everything else being a leaf node) and at the index + * for a child, store the index of its parent. This means that direct + * children of the root store a 0 in their indexes, children of the first + * child of the root store a 1, and we can "pointer chase" from an index + * through parents back to index 0. + * + * Because of this structure, the value stored at index i must be + * less than i (except for index 0, which is special). This makes it + * slightly easier to search for a given value *p*, because we can start + * at index *p* (or even *p+1*). + * + * Given an index *i* into the vector corresponding to a child, we know the + * parent, but can also count which row this child should have, by counting + * *other* indexes before *i* with the same parent (and by the ordering + * of values, we can start counting at index *parent-index*). + * + */ + IndexVector m_rows; + + /// @brief Implementation of walking an index through the variant-tree + const QVariant underlying( const QModelIndex& index ) const; + + /// @brief Helpers for range-checking + inline bool inRange( quintptr p ) const { return p < static_cast< quintptr >( m_rows.count() ); } + inline bool inRange( const QModelIndex& index ) const { return inRange( index.internalId() ); } +}; + +#endif diff --git a/calamares/src/calamares/calamares-navigation.qml b/calamares/src/calamares/calamares-navigation.qml new file mode 100644 index 0000000..becc1b4 --- /dev/null +++ b/calamares/src/calamares/calamares-navigation.qml @@ -0,0 +1,83 @@ +/* Sample of QML navigation. + + SPDX-FileCopyrightText: 2020 Adriaan de Groot + SPDX-License-Identifier: GPL-3.0-or-later + + + The navigation panel is generally "horizontal" in layout, with + buttons for next and previous; this particular one copies + the layout and size of the widgets panel. +*/ +import io.calamares.ui 1.0 +import io.calamares.core 1.0 + +import QtQuick 2.3 +import QtQuick.Controls 2.10 +import QtQuick.Layouts 1.3 + +Rectangle { + id: navigationBar; + color: Branding.styleString( Branding.SidebarBackground ); + height: 48; + + RowLayout { + id: buttonBar + anchors.fill: parent; + + Item + { + Layout.fillWidth: true; + } + + Button + { + text: ViewManager.backLabel; + icon.name: ViewManager.backIcon; + + enabled: ViewManager.backEnabled; + visible: ViewManager.backAndNextVisible; + onClicked: { ViewManager.back(); } + } + Button + { + text: ViewManager.nextLabel; + icon.name: ViewManager.nextIcon; + + enabled: ViewManager.nextEnabled; + visible: ViewManager.backAndNextVisible; + onClicked: { ViewManager.next(); } + // This margin goes in the "next" button, because the "quit" + // button can vanish and we want to keep the margin to + // the next-thing-in-the-navigation-panel around. + Layout.rightMargin: 3 * buttonBar.spacing; + } + Button + { + Layout.rightMargin: 2 * buttonBar.spacing + text: ViewManager.quitLabel; + icon.name: ViewManager.quitIcon; + + ToolTip.visible: hovered + ToolTip.timeout: 5000 + ToolTip.delay: 1000 + ToolTip.text: ViewManager.quitTooltip; + + /* + * The ViewManager has settings -- user-controlled via the + * branding component, and party based on program state -- + * whether the quit button should be enabled and visible. + * + * QML navigation *should* follow this pattern, but can also + * add other qualifications. For instance, you may have a + * "finished" module that handles quit in its own way, and + * want to hide the quit button then. The ViewManager has a + * current step and a total count, so compare them: + * + * visible: ViewManager.quitVisible && ( ViewManager.currentStepIndex < ViewManager.rowCount()-1); + */ + enabled: ViewManager.quitEnabled; + visible: ViewManager.quitVisible; + onClicked: { ViewManager.quit(); } + } + } +} diff --git a/calamares/src/calamares/calamares-sidebar.qml b/calamares/src/calamares/calamares-sidebar.qml new file mode 100644 index 0000000..4780823 --- /dev/null +++ b/calamares/src/calamares/calamares-sidebar.qml @@ -0,0 +1,125 @@ +/* Sample of QML progress tree. + + SPDX-FileCopyrightText: 2020 Adriaan de Groot + SPDX-FileCopyrightText: 2021 Anke Boersma + SPDX-License-Identifier: GPL-3.0-or-later + + + The progress tree (actually a list) is generally "vertical" in layout, + with the steps going "down", but it could also be a more compact + horizontal layout with suitable branding settings. + + This example emulates the layout and size of the widgets progress tree. +*/ +import io.calamares.ui 1.0 +import io.calamares.core 1.0 + +import QtQuick 2.3 +import QtQuick.Layouts 1.3 + +Rectangle { + id: sideBar; + color: Branding.styleString( Branding.SidebarBackground ); + anchors.fill: parent; + + ColumnLayout { + anchors.fill: parent; + spacing: 0; + + Image { + Layout.topMargin: 12; + Layout.bottomMargin: 12; + Layout.alignment: Qt.AlignHCenter | Qt.AlignTop + id: logo; + width: 80; + height: width; // square + source: "file:/" + Branding.imagePath(Branding.ProductLogo); + sourceSize.width: width; + sourceSize.height: height; + } + + Repeater { + model: ViewManager + Rectangle { + Layout.leftMargin: 6; + Layout.rightMargin: 6; + Layout.fillWidth: true; + height: 35; + radius: 6; + color: Branding.styleString( index == ViewManager.currentStepIndex ? Branding.SidebarBackgroundCurrent : Branding.SidebarBackground ); + + Text { + anchors.verticalCenter: parent.verticalCenter; + anchors.horizontalCenter: parent.horizontalCenter; + color: Branding.styleString( index == ViewManager.currentStepIndex ? Branding.SidebarTextCurrent : Branding.SidebarText ); + text: display; + } + } + } + + Item { + Layout.fillHeight: true; + } + + Rectangle { + id: metaArea + Layout.fillWidth: true; + height: 35 + Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom + color: Branding.styleString( Branding.SidebarBackground ); + visible: true; + + Rectangle { + id: aboutArea + height: 35 + width: parent.width / 2; + anchors.left: parent.left + color: Branding.styleString( Branding.SidebarBackgroundCurrent ); + visible: true; + + MouseArea { + id: mouseAreaAbout + anchors.fill: parent; + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + Text { + anchors.verticalCenter: parent.verticalCenter; + anchors.horizontalCenter: parent.horizontalCenter; + x: parent.x + 4; + text: qsTr("About") + color: Branding.styleString( Branding.SidebarTextCurrent ); + font.pointSize : 9 + } + + onClicked: debug.about() + } + } + + Rectangle { + id: debugArea + height: 35 + width: parent.width / 2; + anchors.right: parent.right + color: Branding.styleString( Branding.SidebarBackgroundCurrent ); + visible: debug.enabled + + MouseArea { + id: mouseAreaDebug + anchors.fill: parent; + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + Text { + anchors.verticalCenter: parent.verticalCenter; + anchors.horizontalCenter: parent.horizontalCenter; + x: parent.x + 4; + text: qsTr("Debug") + color: Branding.styleString( Branding.SidebarTextCurrent ); + font.pointSize : 9 + } + + onClicked: debug.toggle() + } + } + } + } +} diff --git a/calamares/src/calamares/calamares.qrc b/calamares/src/calamares/calamares.qrc new file mode 100644 index 0000000..5733ea0 --- /dev/null +++ b/calamares/src/calamares/calamares.qrc @@ -0,0 +1,10 @@ + + + + + calamares-sidebar.qml + calamares-navigation.qml + + diff --git a/calamares/src/calamares/main.cpp b/calamares/src/calamares/main.cpp new file mode 100644 index 0000000..6a22cc6 --- /dev/null +++ b/calamares/src/calamares/main.cpp @@ -0,0 +1,154 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CalamaresApplication.h" + +#include "Settings.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" + +// From 3rdparty/ +#include "kdsingleapplication.h" + +#include +#ifdef BUILD_CRASH_REPORTING +#include +#endif + +#include +#include +#include + +#include + +/** @brief Gets debug-level from -D command-line-option + * + * If unset, use LOGERROR (corresponding to -D1), although + * effectively -D2 is the lowest level you can set for + * logging-to-the-console, and everything always gets + * logged to the session file). + */ +static unsigned int +debug_level( QCommandLineParser& parser, QCommandLineOption& levelOption ) +{ + if ( !parser.isSet( levelOption ) ) + { + return Logger::LOGERROR; + } + + bool ok = true; + int l = parser.value( levelOption ).toInt( &ok ); + if ( !ok || ( l < 0 ) ) + { + return Logger::LOGVERBOSE; + } + else + { + return static_cast< unsigned int >( l ); // l >= 0 + } +} + +/** @brief Handles the command-line arguments + * + * Sets up internals for Calamares based on command-line arguments like `-D`, + * `-d`, etc. Returns @c true if this is a *debug* run, i.e. if the `-d` + * command-line flag is given, @c false otherwise. + */ +static bool +handle_args( CalamaresApplication& a ) +{ + QCommandLineOption debugOption( QStringList { "d", "debug" }, + "Also look in current directory for configuration. Implies -D8." ); + QCommandLineOption debugLevelOption( + QStringLiteral( "D" ), "Verbose output for debugging purposes (0-8).", "level" ); + QCommandLineOption debugTxOption( QStringList { "T", "debug-translation" }, + "Also look in the current directory for translation." ); + + QCommandLineOption configOption( + QStringList { "c", "config" }, "Configuration directory to use, for testing purposes.", "config" ); + QCommandLineOption xdgOption( QStringList { "X", "xdg-config" }, "Use XDG_{CONFIG,DATA}_DIRS as well." ); + + QCommandLineParser parser; + parser.setApplicationDescription( "Distribution-independent installer framework" ); + parser.addHelpOption(); + parser.addVersionOption(); + + parser.addOption( debugOption ); + parser.addOption( debugLevelOption ); + parser.addOption( configOption ); + parser.addOption( xdgOption ); + parser.addOption( debugTxOption ); + + parser.process( a ); + + Logger::setupLogLevel( parser.isSet( debugOption ) ? Logger::LOGVERBOSE : debug_level( parser, debugLevelOption ) ); + if ( parser.isSet( configOption ) ) + { + Calamares::setAppDataDir( QDir( parser.value( configOption ) ) ); + } + if ( parser.isSet( xdgOption ) ) + { + Calamares::setXdgDirs(); + } + Calamares::setAllowLocalTranslation( parser.isSet( debugOption ) || parser.isSet( debugTxOption ) ); + + return parser.isSet( debugOption ); +} + +int +main( int argc, char* argv[] ) +{ +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + // Not needed in Qt6 + QApplication::setAttribute( Qt::AA_EnableHighDpiScaling ); +#endif + CalamaresApplication a( argc, argv ); + + KAboutData aboutData( "calamares", + "Calamares", + a.applicationVersion(), + "The universal system installer", + KAboutLicense::GPL_V3, + QString(), + QString(), + "https://calamares.io", + "https://codeberg.org/Calamares/calamares/issues" ); + KAboutData::setApplicationData( aboutData ); + a.setApplicationDisplayName( QString() ); // To avoid putting an extra "Calamares/" into the log-file + +#ifdef BUILD_CRASH_REPORTING + KCrash::initialize(); + // KCrash::setCrashHandler(); + KCrash::setDrKonqiEnabled( true ); + KCrash::setFlags( KCrash::SaferDialog | KCrash::AlwaysDirectly ); +#endif + + std::unique_ptr< KDSingleApplication > possiblyUnique; + const bool is_debug = handle_args( a ); + if ( !is_debug ) + { + possiblyUnique = std::make_unique< KDSingleApplication >(); + if ( !possiblyUnique->isPrimaryInstance() ) + { + qCritical() << "Calamares is already running."; + return 87; // EUSERS on Linux + } + } + + Calamares::Settings::init( is_debug ); + if ( !Calamares::Settings::instance() || !Calamares::Settings::instance()->isValid() ) + { + qCritical() << "Calamares has invalid settings, shutting down."; + return 78; // EX_CONFIG on FreeBSD + } + a.init(); + return a.exec(); +} diff --git a/calamares/src/calamares/progresstree/ProgressTreeDelegate.cpp b/calamares/src/calamares/progresstree/ProgressTreeDelegate.cpp new file mode 100644 index 0000000..df513f2 --- /dev/null +++ b/calamares/src/calamares/progresstree/ProgressTreeDelegate.cpp @@ -0,0 +1,119 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ProgressTreeDelegate.h" + +#include "Branding.h" +#include "CalamaresApplication.h" +#include "CalamaresWindow.h" +#include "ViewManager.h" +#include "utils/Gui.h" + +#include + +static constexpr int const item_margin = 8; +static inline int +item_fontsize() +{ + return Calamares::defaultFontSize() + 4; +} + +static void +paintViewStep( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) +{ + QRect textRect = option.rect.adjusted( item_margin, item_margin, -item_margin, -item_margin ); + QFont font = qApp->font(); + font.setPointSize( item_fontsize() ); + font.setBold( false ); + painter->setFont( font ); + + if ( index.row() == index.data( Calamares::ViewManager::ProgressTreeItemCurrentIndex ).toInt() ) + { + painter->setPen( Calamares::Branding::instance()->styleString( Calamares::Branding::SidebarTextCurrent ) ); + QString textHighlight + = Calamares::Branding::instance()->styleString( Calamares::Branding::SidebarBackgroundCurrent ); + if ( textHighlight.isEmpty() ) + { + painter->setBrush( CalamaresApplication::instance()->mainWindow()->palette().window() ); + } + else + { + painter->setBrush( QColor( textHighlight ) ); + } + } + + // Draw the text at least once. If it doesn't fit, then shrink the font + // being used by 1 pt on each iteration, up to a maximum of maximumShrink + // times. On each loop, we'll have to blank out the rectangle again, so this + // is an expensive (in terms of drawing operations) thing to do. + // + // (The loop uses <= because the counter is incremented at the start). + static constexpr int const maximumShrink = 4; + int shrinkSteps = 0; + do + { + painter->fillRect( option.rect, painter->brush().color() ); + shrinkSteps++; + + QRectF boundingBox; + painter->drawText( + textRect, Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextSingleLine, index.data().toString(), &boundingBox ); + + // The extra check here is to avoid the changing-font-size if we're not going to use + // it in the next iteration of the loop anyway. + if ( ( shrinkSteps <= maximumShrink ) && ( boundingBox.width() > textRect.width() ) ) + { + font.setPointSize( item_fontsize() - shrinkSteps ); + painter->setFont( font ); + } + else + { + break; // It fits + } + } while ( shrinkSteps <= maximumShrink ); +} + +QSize +ProgressTreeDelegate::sizeHint( const QStyleOptionViewItem& option, const QModelIndex& index ) const +{ + if ( !index.isValid() ) + { + return option.rect.size(); + } + + QFont font = qApp->font(); + + font.setPointSize( item_fontsize() ); + QFontMetrics fm( font ); + int height = fm.height(); + + height += 2 * item_margin; + + return QSize( option.rect.width(), height ); +} + +void +ProgressTreeDelegate::paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const +{ + QStyleOptionViewItem opt = option; + + painter->save(); + + initStyleOption( &opt, index ); + opt.text.clear(); + + painter->setBrush( + QColor( Calamares::Branding::instance()->styleString( Calamares::Branding::SidebarBackground ) ) ); + painter->setPen( QColor( Calamares::Branding::instance()->styleString( Calamares::Branding::SidebarText ) ) ); + + paintViewStep( painter, opt, index ); + + painter->restore(); +} diff --git a/calamares/src/calamares/progresstree/ProgressTreeDelegate.h b/calamares/src/calamares/progresstree/ProgressTreeDelegate.h new file mode 100644 index 0000000..d5a5abc --- /dev/null +++ b/calamares/src/calamares/progresstree/ProgressTreeDelegate.h @@ -0,0 +1,31 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PROGRESSTREEDELEGATE_H +#define PROGRESSTREEDELEGATE_H + +#include + +/** + * @brief The ProgressTreeDelegate class customizes the look and feel of the + * ProgressTreeView elements. + * @see ProgressTreeView + */ +class ProgressTreeDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + +protected: + QSize sizeHint( const QStyleOptionViewItem& option, const QModelIndex& index ) const override; + void paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const override; +}; + +#endif // PROGRESSTREEDELEGATE_H diff --git a/calamares/src/calamares/progresstree/ProgressTreeView.cpp b/calamares/src/calamares/progresstree/ProgressTreeView.cpp new file mode 100644 index 0000000..9d49ea2 --- /dev/null +++ b/calamares/src/calamares/progresstree/ProgressTreeView.cpp @@ -0,0 +1,61 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ProgressTreeView.h" + +#include "ProgressTreeDelegate.h" + +#include "Branding.h" +#include "ViewManager.h" + +ProgressTreeView::ProgressTreeView( QWidget* parent ) + : QListView( parent ) +{ + this->setObjectName( "sidebarMenuApp" ); + setFrameShape( QFrame::NoFrame ); + setContentsMargins( 0, 0, 0, 0 ); + + setSelectionMode( QAbstractItemView::NoSelection ); + setDragDropMode( QAbstractItemView::NoDragDrop ); + setAcceptDrops( false ); + + setItemDelegate( new ProgressTreeDelegate( this ) ); + + QPalette plt = palette(); + plt.setColor( QPalette::Base, + Calamares::Branding::instance()->styleString( Calamares::Branding::SidebarBackground ) ); + setPalette( plt ); +} + + +ProgressTreeView::~ProgressTreeView() {} + + +void +ProgressTreeView::setModel( QAbstractItemModel* model ) +{ + if ( ProgressTreeView::model() ) + { + return; + } + + QListView::setModel( model ); + + connect( Calamares::ViewManager::instance(), + &Calamares::ViewManager::currentStepChanged, + this, + &ProgressTreeView::update, + Qt::UniqueConnection ); +} + +void +ProgressTreeView::update() +{ + viewport()->update(); +} diff --git a/calamares/src/calamares/progresstree/ProgressTreeView.h b/calamares/src/calamares/progresstree/ProgressTreeView.h new file mode 100644 index 0000000..d845cc7 --- /dev/null +++ b/calamares/src/calamares/progresstree/ProgressTreeView.h @@ -0,0 +1,39 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PROGRESSTREEVIEW_H +#define PROGRESSTREEVIEW_H + +#include + +/** + * @brief Displays progress through the list of (visible) steps + * + * The ProgressTreeView class is a modified QListView which displays the + * available view steps and the user's progress through them. + * Since Calamares doesn't support "sub steps", it isn't really a tree. + */ +class ProgressTreeView : public QListView +{ + Q_OBJECT +public: + explicit ProgressTreeView( QWidget* parent = nullptr ); + ~ProgressTreeView() override; + + /** + * @brief setModel assigns a model to this view. + */ + void setModel( QAbstractItemModel* model ) override; + +public Q_SLOTS: + void update(); +}; + +#endif // PROGRESSTREEVIEW_H diff --git a/calamares/src/calamares/test_conf.cpp b/calamares/src/calamares/test_conf.cpp new file mode 100644 index 0000000..73b19aa --- /dev/null +++ b/calamares/src/calamares/test_conf.cpp @@ -0,0 +1,109 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/** + * This is a test-application that just checks the YAML config-file + * shipped with each module for correctness -- well, for parseability. + */ + +#include "utils/Yaml.h" + +#include +#include + +#include + +#include +#include + +using std::cerr; + +static const char usage[] = "Usage: test_conf [-v] [-b] ...\n"; + +int +main( int argc, char** argv ) +{ + bool verbose = false; + bool bytes = false; + + int opt; + while ( ( opt = getopt( argc, argv, "vb" ) ) != -1 ) + { + switch ( opt ) + { + case 'v': + verbose = true; + break; + case 'b': + bytes = true; + break; + default: /* '?' */ + cerr << usage; + return 1; + } + } + + if ( optind >= argc ) + { + cerr << usage; + return 1; + } + + const char* filename = argv[ optind ]; + try + { + YAML::Node doc; + if ( bytes ) + { + QFile f( filename ); + if ( f.open( QFile::ReadOnly | QFile::Text ) ) + { + doc = YAML::Load( f.readAll().constData() ); + } + } + else + { + doc = YAML::LoadFile( filename ); + } + + if ( doc.IsNull() ) + { + // Special case: empty config files are valid, + // but aren't a map. For the example configs, + // this is still an error. + cerr << "WARNING:" << filename << '\n'; + cerr << "WARNING: empty YAML\n"; + return 1; + } + + if ( !doc.IsMap() ) + { + cerr << "WARNING:" << filename << '\n'; + cerr << "WARNING: not-a-YAML-map (type=" << doc.Type() << ")\n"; + return 1; + } + + if ( verbose ) + { + cerr << "Keys:\n"; + for ( auto i = doc.begin(); i != doc.end(); ++i ) + { + cerr << i->first.as< std::string >() << '\n'; + } + } + } + catch ( YAML::Exception& e ) + { + cerr << "WARNING:" << filename << '\n'; + cerr << "WARNING: YAML parser error " << e.what() << '\n'; + return 1; + } + + return 0; +} diff --git a/calamares/src/calamares/testmain.cpp b/calamares/src/calamares/testmain.cpp new file mode 100644 index 0000000..c9d89c0 --- /dev/null +++ b/calamares/src/calamares/testmain.cpp @@ -0,0 +1,573 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* + * This executable loads and runs a Calamares Python module + * within a C++ application, in order to test the different + * bindings. + */ + +#include "Branding.h" +#include "CppJob.h" +#include "GlobalStorage.h" +#include "Job.h" +#include "JobQueue.h" +#include "Settings.h" +#include "ViewManager.h" +#include "modulesystem/Module.h" +#include "modulesystem/ModuleManager.h" +#include "modulesystem/ViewModule.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/System.h" +#include "utils/Yaml.h" +#include "viewpages/ExecutionViewStep.h" + +// Optional features of Calamares +// - Python support with pybind11 +// - Python support with older Boost implementation +// - QML support +#ifdef WITH_PYTHON +#ifdef WITH_PYBIND11 +#include "pybind11/PythonJob.h" +#else +#include "pyboost/PythonJob.h" +#endif +#endif +#ifdef WITH_QML +#include "utils/Qml.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +struct ModuleConfig +{ + QString moduleName() const { return m_module; } + QString configFile() const { return m_jobConfig; } + QString language() const { return m_language; } + QString globalConfigFile() const { return m_globalConfig; } + + QString m_module; + QString m_jobConfig; + QString m_globalConfig; + QString m_settingsConfig; + QString m_language; + QString m_branding; + bool m_ui; + bool m_pythonInjection; +}; + +static ModuleConfig +handle_args( QCoreApplication& a ) +{ + QCommandLineOption debugLevelOption( + QStringLiteral( "D" ), "Verbose output for debugging purposes (0-8), ignored.", "level" ); + QCommandLineOption settingsOption( { QStringLiteral( "S" ), QStringLiteral( "settings" ) }, + QStringLiteral( "Settings.conf document" ), + QString( "settings.conf" ) ); + QCommandLineOption globalOption( { QStringLiteral( "g" ), QStringLiteral( "global" ) }, + QStringLiteral( "Global storage settings document" ), + "global.yaml" ); + QCommandLineOption jobOption( + { QStringLiteral( "j" ), QStringLiteral( "job" ) }, QStringLiteral( "Job settings document" ), "job.yaml" ); + QCommandLineOption langOption( { QStringLiteral( "l" ), QStringLiteral( "language" ) }, + QStringLiteral( "Language (global)" ), + "languagecode" ); + QCommandLineOption brandOption( { QStringLiteral( "b" ), QStringLiteral( "branding" ) }, + QStringLiteral( "Branding directory" ), + "path/to/branding.desc", + "src/branding/default/branding.desc" ); + QCommandLineOption uiOption( { QStringLiteral( "U" ), QStringLiteral( "ui" ) }, QStringLiteral( "Enable UI" ) ); + QCommandLineOption slideshowOption( { QStringLiteral( "s" ), QStringLiteral( "slideshow" ) }, + QStringLiteral( "Run slideshow module" ) ); + QCommandLineParser parser; + parser.setApplicationDescription( "Calamares module tester" ); + parser.addHelpOption(); + parser.addVersionOption(); + + parser.addOption( debugLevelOption ); + parser.addOption( settingsOption ); + parser.addOption( globalOption ); + parser.addOption( jobOption ); + parser.addOption( langOption ); + parser.addOption( brandOption ); + parser.addOption( uiOption ); + parser.addOption( slideshowOption ); +#ifdef WITH_PYTHON + QCommandLineOption pythonOption( { QStringLiteral( "P" ), QStringLiteral( "no-injected-python" ) }, + QStringLiteral( "Do not disable potentially-harmful Python commands" ) ); + parser.addOption( pythonOption ); +#endif + + parser.addPositionalArgument( "module", "Path or name of module to run." ); + parser.addPositionalArgument( "job.yaml", "Path of job settings document to use.", "[job.yaml]" ); + + parser.process( a ); + + const QStringList args = parser.positionalArguments(); + if ( args.isEmpty() && !parser.isSet( slideshowOption ) ) + { + cError() << "Missing path.\n"; + parser.showHelp(); + } + else if ( args.size() > 2 ) + { + cError() << "More than one path.\n"; + parser.showHelp(); + } + else + { + QString jobSettings( parser.value( jobOption ) ); + if ( jobSettings.isEmpty() && ( args.size() == 2 ) ) + { + jobSettings = args.at( 1 ); + } + + bool pythonInjection = true; +#ifdef WITH_PYTHON + if ( parser.isSet( pythonOption ) ) + { + pythonInjection = false; + } +#endif + return ModuleConfig { parser.isSet( slideshowOption ) ? QStringLiteral( "-" ) : args.first(), + jobSettings, + parser.value( globalOption ), + parser.value( settingsOption ), + parser.value( langOption ), + parser.value( brandOption ), + parser.isSet( slideshowOption ) || parser.isSet( uiOption ), + pythonInjection }; + } +} + +/** @brief Bogus Job for --slideshow option + * + * Generally one would use DummyCppJob for this kind of dummy + * job, but that class lives in a module so isn't available + * in this test application. + * + * This bogus job just sleeps for 3. + */ +class ExecViewJob : public Calamares::CppJob +{ +public: + explicit ExecViewJob( const QString& name, unsigned long t = 3 ) + : m_name( name ) + , m_delay( t ) + { + } + ~ExecViewJob() override; + + QString prettyName() const override { return m_name; } + + Calamares::JobResult exec() override + { + QThread::sleep( m_delay ); + return Calamares::JobResult::ok(); + } + + void setConfigurationMap( const QVariantMap& ) override {} + +private: + QString m_name; + unsigned long m_delay; +}; + +ExecViewJob::~ExecViewJob() {} + +/** @brief Bogus module for --slideshow option + * + * Normally the slideshow -- displayed by ExecutionViewStep -- is not + * associated with any particular module in the Calamares configuration. + * It is added internally by the module manager. For the module-loader + * testing application, we need something that pretends to be the + * module for the ExecutionViewStep. + */ +class ExecViewModule : public Calamares::Module +{ +public: + ExecViewModule(); + ~ExecViewModule() override; + + void loadSelf() override; + + virtual Calamares::ModuleSystem::Type type() const override; + virtual Calamares::ModuleSystem::Interface interface() const override; + + virtual Calamares::JobList jobs() const override; + +protected: + void initFrom( const Calamares::ModuleSystem::Descriptor& ) override; +}; + +ExecViewModule::ExecViewModule() + : Calamares::Module() +{ + // Normally the module-loader gives the module an instance key + // (out of the settings file, or the descriptor of the module). + // We don't have one, so build one -- this gives us "execView@execView". + QVariantMap m; + const QString execView = QStringLiteral( "execView" ); + m.insert( "name", execView ); + Calamares::Module::initFrom( Calamares::ModuleSystem::Descriptor::fromDescriptorData( m, execView ), execView ); +} + +ExecViewModule::~ExecViewModule() {} + +void +ExecViewModule::initFrom( const Calamares::ModuleSystem::Descriptor& ) +{ +} + +void +ExecViewModule::loadSelf() +{ + auto* viewStep = new Calamares::ExecutionViewStep(); + viewStep->setModuleInstanceKey( instanceKey() ); + viewStep->setConfigurationMap( m_configurationMap ); + viewStep->appendJobModuleInstanceKey( instanceKey() ); + Calamares::ViewManager::instance()->addViewStep( viewStep ); + m_loaded = true; +} + +Calamares::Module::Type +ExecViewModule::type() const +{ + return Module::Type::View; +} + +Calamares::Module::Interface +ExecViewModule::interface() const +{ + return Module::Interface::QtPlugin; +} + +Calamares::JobList +ExecViewModule::jobs() const +{ + Calamares::JobList l; + const auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( gs && gs->contains( "jobs" ) ) + { + QVariantList joblist = gs->value( "jobs" ).toList(); + for ( const auto& jd : joblist ) + { + QVariantMap jobdescription = jd.toMap(); + if ( jobdescription.contains( "name" ) && jobdescription.contains( "delay" ) ) + { + l.append( Calamares::job_ptr( new ExecViewJob( jobdescription.value( "name" ).toString(), + jobdescription.value( "delay" ).toULongLong() ) ) ); + } + } + } + if ( l.count() > 0 ) + { + return l; + } + + l.append( Calamares::job_ptr( new ExecViewJob( QStringLiteral( "step 1" ) ) ) ); + l.append( Calamares::job_ptr( new ExecViewJob( QStringLiteral( "step two" ) ) ) ); + l.append( Calamares::job_ptr( new ExecViewJob( QStringLiteral( "locking mutexes" ), 20 ) ) ); + l.append( Calamares::job_ptr( new ExecViewJob( QStringLiteral( "unlocking mutexes" ), 1 ) ) ); + for ( const QString& s : QStringList { "Harder", "Better", "Faster", "Stronger" } ) + { + l.append( Calamares::job_ptr( new ExecViewJob( s, 0 ) ) ); + } + l.append( Calamares::job_ptr( new ExecViewJob( QStringLiteral( "cleaning up" ), 20 ) ) ); + return l; +} + +static Calamares::Module* +load_module( const ModuleConfig& moduleConfig ) +{ + QString moduleName = moduleConfig.moduleName(); + if ( moduleName == "-" ) + { + return new ExecViewModule; + } + + QFileInfo fi; // This is kept around to hold the path of the module descriptor + + bool ok = false; + QVariantMap descriptor; + + QStringList moduleDirectories { "./", "src/modules/", "modules/", CMAKE_INSTALL_FULL_LIBDIR "/calamares/modules/" }; + for ( const QString& prefix : std::as_const( moduleDirectories ) ) + { + // Could be a complete path, eg. src/modules/dummycpp/module.desc + fi = QFileInfo( prefix + moduleName ); + if ( fi.exists() && fi.isFile() ) + { + descriptor = Calamares::YAML::load( fi, &ok ); + } + if ( ok ) + { + break; + } + + // Could be a path without module.desc + fi = QFileInfo( prefix + moduleName ); + if ( fi.exists() && fi.isDir() ) + { + fi = QFileInfo( prefix + moduleName + "/module.desc" ); + if ( fi.exists() && fi.isFile() ) + { + descriptor = Calamares::YAML::load( fi, &ok ); + } + if ( ok ) + { + break; + } + else + { + if ( !fi.exists() ) + { + cDebug() << "Expected a descriptor file" << fi.path(); + } + else + { + cDebug() << "Read descriptor" << fi.path() << "and it was empty."; + } + } + } + } + + if ( !ok ) + { + cWarning() << "No suitable module descriptor found in" << Logger::DebugList( moduleDirectories ); + return nullptr; + } + + QString name = descriptor.value( "name" ).toString(); + if ( name.isEmpty() ) + { + cWarning() << "No name found in module descriptor" << fi.absoluteFilePath(); + return nullptr; + } + + QString moduleDirectory = fi.absolutePath(); + QString configFile( moduleConfig.configFile().isEmpty() ? moduleDirectory + '/' + name + ".conf" + : moduleConfig.configFile() ); + + cDebug() << Logger::SubEntry << "Module" << moduleName << "job-configuration:" << configFile; + + Calamares::Module* module = Calamares::moduleFromDescriptor( + Calamares::ModuleSystem::Descriptor::fromDescriptorData( descriptor, fi.absoluteFilePath() ), + name, + configFile, + moduleDirectory ); + + return module; +} + +static bool +is_ui_option( const char* s ) +{ + return !qstrcmp( s, "--ui" ) || !qstrcmp( s, "-U" ); +} + +static bool +is_slideshow_option( const char* s ) +{ + return !qstrcmp( s, "--slideshow" ) || !qstrcmp( s, "-s" ); +} + +/** @brief Create the right kind of QApplication + * + * Does primitive parsing of argv[] to find the --ui option and returns + * a UI-enabled application if it does. + * + * @p argc must be a reference (to main's argc) because the QCoreApplication + * constructors take a reference as well, and that would otherwise be a + * reference to a temporary. + */ +QCoreApplication* +createApplication( int& argc, char* argv[] ) +{ + for ( int i = 1; i < argc; ++i ) + { + if ( is_slideshow_option( argv[ i ] ) || is_ui_option( argv[ i ] ) ) + { + auto* aw = new QApplication( argc, argv ); + aw->setQuitOnLastWindowClosed( true ); + return aw; + } + } + return new QCoreApplication( argc, argv ); +} + +#ifdef WITH_PYTHON +static const char pythonPreScript[] = R"%( +# This is Python code executed by Python modules *before* the +# script file (e.g. main.py) is executed. +# +# Calls to suprocess methods that execute something are +# suppressed and logged -- scripts should really be using libcalamares +# methods instead. +_calamares_subprocess = __import__("subprocess", globals(), locals(), [], 0) +import sys +import libcalamares +class fake_subprocess(object): + PIPE = object() + STDOUT = object() + STDERR = object() + class CompletedProcess(object): + returncode = 0 + stdout = "" + stderr = "" + @staticmethod + def call(*args, **kwargs): + libcalamares.utils.debug("subprocess.call(%r,%r) X ignored" % (args, kwargs)) + return 0 + @staticmethod + def check_call(*args, **kwargs): + libcalamares.utils.debug("subprocess.check_call(%r,%r) X ignored" % (args, kwargs)) + return 0 + # This is a 3.5-and-later method, is supposed to return a CompletedProcess + @staticmethod + def run(*args, **kwargs): + libcalamares.utils.debug("subprocess.run(%r,%r) X ignored" % (args, kwargs)) + return fake_subprocess.CompletedProcess() +for attr in ("CalledProcessError",): + setattr(fake_subprocess,attr,getattr(_calamares_subprocess,attr)) +sys.modules["subprocess"] = fake_subprocess +libcalamares.utils.debug('pre-script for testing purposes injected') + +)%"; +#endif + +int +main( int argc, char* argv[] ) +{ + QCoreApplication* application = createApplication( argc, argv ); + + Logger::setupLogLevel( Logger::LOGVERBOSE ); + + ModuleConfig module = handle_args( *application ); + if ( module.moduleName().isEmpty() ) + { + return 1; + } + + std::unique_ptr< Calamares::Settings > settings_p( Calamares::Settings::init( module.m_settingsConfig ) ); + std::unique_ptr< Calamares::JobQueue > jobqueue_p( new Calamares::JobQueue( nullptr ) ); + std::unique_ptr< Calamares::System > system_p( new Calamares::System( settings_p->doChroot() ) ); + + QMainWindow* mainWindow = nullptr; + + auto* gs = jobqueue_p->globalStorage(); + if ( !module.globalConfigFile().isEmpty() ) + { + gs->loadYaml( module.globalConfigFile() ); + } + if ( !module.language().isEmpty() ) + { + QVariantMap vm; + vm.insert( "LANG", module.language() ); + gs->insert( "localeConf", vm ); + } + +#ifdef WITH_PYTHON + if ( module.m_pythonInjection ) + { +#ifdef WITH_PYBIND11 + Calamares::Python::Job::setInjectedPreScript( pythonPreScript ); +#else + // Old Boost approach + Calamares::PythonJob::setInjectedPreScript( pythonPreScript ); +#endif + } +#endif +#ifdef WITH_QML + Calamares::initQmlModulesDir(); // don't care if failed +#endif + + cDebug() << "Calamares module-loader testing" << module.moduleName(); + std::unique_ptr m( load_module( module ) ); + std::unique_ptr modulemanager; + if ( !m ) + { + cError() << "Could not load module" << module.moduleName(); + return 1; + } + + cDebug() << Logger::SubEntry << "got" << m->name() << m->typeString() << m->interfaceString(); + if ( m->type() == Calamares::Module::Type::View ) + { + // If we forgot the --ui, any ViewModule will core dump as it + // tries to create the widget **which won't be used anyway**. + // + // To avoid that crash, re-create the QApplication, now with GUI + if ( !qobject_cast< QApplication* >( application ) ) + { + auto* replace_app = new QApplication( argc, argv ); + replace_app->setQuitOnLastWindowClosed( true ); + application = replace_app; + } + mainWindow = module.m_ui ? new QMainWindow() : nullptr; + if ( mainWindow ) + { + mainWindow->installEventFilter( Calamares::Retranslator::instance() ); + } + + (void)new Calamares::Branding( module.m_branding ); + modulemanager = std::make_unique( QStringList(), nullptr ); + (void)Calamares::ViewManager::instance( mainWindow ); + modulemanager->addModule( m.release() ); // Transfers ownership + } + + if ( !m->isLoaded() ) + { + m->loadSelf(); + } + + if ( !m->isLoaded() ) + { + cError() << "Module" << module.moduleName() << "could not be loaded."; + return 1; + } + + if ( mainWindow ) + { + auto* vm = Calamares::ViewManager::instance(); + vm->onInitComplete(); + QWidget* w = vm->currentStep()->widget(); + w->setParent( mainWindow ); + mainWindow->setCentralWidget( w ); + w->show(); + mainWindow->show(); + return application->exec(); + } + + using TR = Logger::DebugRow< const char*, const QString >; + + cDebug() << Logger::SubEntry << "Module metadata" << TR( "name", m->name() ) << TR( "type", m->typeString() ) + << TR( "interface", m->interfaceString() ); + + Calamares::JobQueue::instance()->enqueue( 100, m->jobs() ); + + QObject::connect( Calamares::JobQueue::instance(), + &Calamares::JobQueue::finished, + [ application ]() + { QTimer::singleShot( std::chrono::seconds( 3 ), application, &QApplication::quit ); } ); + QTimer::singleShot( 0, []() { Calamares::JobQueue::instance()->start(); } ); + + return application->exec(); +} diff --git a/calamares/src/libcalamares/CMakeLists.txt b/calamares/src/libcalamares/CMakeLists.txt new file mode 100644 index 0000000..57a0452 --- /dev/null +++ b/calamares/src/libcalamares/CMakeLists.txt @@ -0,0 +1,255 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# +# libcalamares is the non-GUI part of Calamares, which includes handling +# translations, configurations, logging, utilities, global storage, and +# (non-GUI) jobs. +# + +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/CalamaresConfig.h.in ${CMAKE_CURRENT_BINARY_DIR}/CalamaresConfig.h) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/CalamaresVersion.h.in ${CMAKE_CURRENT_BINARY_DIR}/CalamaresVersion.h) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/CalamaresVersionX.h.in ${CMAKE_CURRENT_BINARY_DIR}/CalamaresVersionX.h) + +# Map the available translations names into a suitable constexpr list +# of names in C++. This gets us Calamares::Locale::availableLanguages, +# a QStringList of names. +set(_names_tu + " +#ifndef CALAMARES_TRANSLATIONS_H +#define CALAMARES_TRANSLATIONS_H +#include +namespace { +static const QStringList availableLanguageList{ +" +) +foreach(l ${CALAMARES_TRANSLATION_LANGUAGES}) + string(APPEND _names_tu "\"${l}\",\n") +endforeach() +string(APPEND _names_tu "};\n} // namespace\n#endif\n\n") +file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/CalamaresTranslations.cc "${_names_tu}") + +# Some implementation details are compiled twice, because +# we want them in the library with visibility("hidden") +# but also need them in tests. +set(_geoip_src + geoip/GeoIPFixed.cpp + geoip/GeoIPJSON.cpp +) + +add_library( + calamares + SHARED + CalamaresAbout.cpp + CppJob.cpp + GlobalStorage.cpp + Job.cpp + JobExample.cpp + JobQueue.cpp + ProcessJob.cpp + Settings.cpp + # GeoIP services + geoip/Interface.cpp + ${_geoip_src} + geoip/Handler.cpp + # Locale-data service + locale/Global.cpp + locale/Lookup.cpp + locale/TimeZone.cpp + locale/TranslatableConfiguration.cpp + locale/TranslatableString.cpp + locale/Translation.cpp + locale/TranslationsModel.cpp + # Modules + modulesystem/Config.cpp + modulesystem/Descriptor.cpp + modulesystem/InstanceKey.cpp + modulesystem/Module.cpp + modulesystem/Preset.cpp + modulesystem/RequirementsChecker.cpp + modulesystem/RequirementsModel.cpp + # Network service + network/Manager.cpp + # Packages service + packages/Globals.cpp + # Partition service + partition/Global.cpp + partition/Mount.cpp + partition/PartitionSize.cpp + partition/Sync.cpp + # Utility service + utils/CommandList.cpp + utils/Dirs.cpp + utils/Entropy.cpp + utils/Logger.cpp + utils/Permissions.cpp + utils/PluginFactory.cpp + utils/Retranslator.cpp + utils/Runner.cpp + utils/String.cpp + utils/StringExpander.cpp + utils/System.cpp + utils/UMask.cpp + utils/Variant.cpp + utils/Yaml.cpp +) + +set_target_properties( + calamares + PROPERTIES + VERSION ${CALAMARES_VERSION_SHORT} + SOVERSION ${CALAMARES_SOVERSION} + CXX_VISIBILITY_PRESET hidden +) +target_link_libraries(calamares LINK_PUBLIC yamlcpp::yamlcpp ${qtname}::Core ${qtname}::Network) +target_link_libraries(calamares LINK_PUBLIC ${kfname}::CoreAddons) + +target_compile_definitions(calamares PRIVATE DLLEXPORT_PRO) +target_include_directories(calamares PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) +target_include_directories(calamares PUBLIC + $ + $ +) +target_include_directories(calamares PUBLIC $) + +### OPTIONAL Automount support (requires dbus) +# +# +if(TARGET ${qtname}::DBus) + target_sources(calamares PRIVATE partition/AutoMount.cpp) + target_link_libraries(calamares PRIVATE ${qtname}::DBus) +endif() + +### OPTIONAL Python support +# +# +if(WITH_PYTHON) + if(WITH_PYBIND11) + target_include_directories(calamares PRIVATE pybind11) + target_sources(calamares PRIVATE pybind11/Api.cpp pybind11/PythonJob.cpp) + target_link_libraries(calamares PRIVATE Python::Python pybind11::headers) + else() + target_include_directories(calamares PRIVATE pyboost) + target_sources(calamares PRIVATE pyboost/PythonHelper.cpp pyboost/PythonJob.cpp pyboost/PythonJobApi.cpp) + target_link_libraries(calamares PRIVATE Python::Python Boost::python) + endif() + target_sources(calamares PRIVATE python/Api.cpp python/Variant.cpp) +endif() + +### OPTIONAL GeoIP XML support +# +# +find_package(${qtname} ${QT_VERSION} COMPONENTS Xml) +if(TARGET ${qtname}::Xml) + target_sources(calamares PRIVATE geoip/GeoIPXML.cpp) + target_link_libraries(calamares PRIVATE ${qtname}::Network ${qtname}::Xml) +endif() + +### OPTIONAL KPMcore support +# +# +include(KPMcoreHelper) + +if(KPMcore_FOUND) + target_sources( + calamares + PRIVATE + partition/FileSystem.cpp + partition/KPMManager.cpp + partition/PartitionIterator.cpp + partition/PartitionQuery.cpp + ) +endif() +# Always, since this also handles the no-KPMcore case; we don't +# call it calamares::kpmcore because that name exists only +# when KPMcore is actually found. +target_link_libraries(calamares PRIVATE calapmcore) + +### LIBRARY +# +# +calamares_automoc( calamares ) +add_library(Calamares::calamares ALIAS calamares) + +### Installation +# +# +install( + TARGETS calamares + EXPORT Calamares + RUNTIME + DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY + DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE + DESTINATION ${CMAKE_INSTALL_LIBDIR} +) + +# Make symlink lib/calamares/libcalamares.so to lib/libcalamares.so.VERSION so +# lib/calamares can be used as module path for the Python interpreter. +install( + CODE + " + file( MAKE_DIRECTORY \"\$ENV{DESTDIR}/${CMAKE_INSTALL_FULL_LIBDIR}/calamares\" ) + execute_process( COMMAND \"${CMAKE_COMMAND}\" -E create_symlink ../libcalamares.so.${CALAMARES_VERSION_SHORT} libcalamares.so WORKING_DIRECTORY \"\$ENV{DESTDIR}/${CMAKE_INSTALL_FULL_LIBDIR}/calamares\" ) +" +) + +# Install header files +file(GLOB rootHeaders "*.h") +install( + FILES ${CMAKE_CURRENT_BINARY_DIR}/CalamaresConfig.h ${CMAKE_CURRENT_BINARY_DIR}/CalamaresVersion.h ${rootHeaders} + DESTINATION include/libcalamares +) +# Install each subdir-worth of header files +foreach(subdir geoip locale modulesystem network partition utils compat packages) + file(GLOB subdir_headers "${subdir}/*.h") + install(FILES ${subdir_headers} DESTINATION include/libcalamares/${subdir}) +endforeach() + +### TRANSLATION TESTING +# + +calamares_qrc_translations( localetest OUTPUT_VARIABLE localetest_qrc SUBDIRECTORY testdata LANGUAGES nl ) + +### TESTING +# +# +calamares_add_test(libcalamarestest SOURCES Tests.cpp) + +calamares_add_test(libcalamaresgeoiptest SOURCES geoip/GeoIPTests.cpp ${_geoip_src}) + +calamares_add_test(libcalamareslocaletest SOURCES locale/Tests.cpp ${localetest_qrc}) + +calamares_add_test(libcalamaresmodulesystemtest SOURCES modulesystem/Tests.cpp) + +calamares_add_test(libcalamaresnetworktest SOURCES network/Tests.cpp) + +calamares_add_test(libcalamarespackagestest SOURCES packages/Tests.cpp) + +if(KPMcore_FOUND) + calamares_add_test( + libcalamarespartitiontest + SOURCES partition/Global.cpp partition/Tests.cpp + LIBRARIES calamares::kpmcore + ) + calamares_add_test(libcalamarespartitionkpmtest SOURCES partition/KPMTests.cpp LIBRARIES calamares::kpmcore) +endif() + +calamares_add_test(libcalamaresutilstest SOURCES utils/Tests.cpp utils/Permissions.cpp utils/Runner.cpp) + +calamares_add_test(libcalamaresutilspathstest SOURCES utils/TestPaths.cpp) + +# This is not an actual test, it's a test / demo application +# for experimenting with GeoIP. +add_executable(test_geoip geoip/test_geoip.cpp ${_geoip_src}) +target_link_libraries(test_geoip Calamares::calamares ${qtname}::Network yamlcpp::yamlcpp) +calamares_automoc( test_geoip ) + +if(TARGET ${qtname}::DBus) + add_executable(test_automount partition/calautomount.cpp) + target_link_libraries(test_automount Calamares::calamares ${qtname}::DBus) +endif() diff --git a/calamares/src/libcalamares/CalamaresAbout.cpp b/calamares/src/libcalamares/CalamaresAbout.cpp new file mode 100644 index 0000000..4ff4435 --- /dev/null +++ b/calamares/src/libcalamares/CalamaresAbout.cpp @@ -0,0 +1,76 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CalamaresAbout.h" + +#include "CalamaresVersionX.h" + +#include + +static const char s_header[] + = QT_TRANSLATE_NOOP( "AboutData", "

%1


%2
for %3


" ); + +static const char s_footer[] + = QT_TRANSLATE_NOOP( "AboutData", + "Thanks to the Calamares team " + "and the Calamares " + "translators team." ); + +struct Maintainer +{ + unsigned int start; + unsigned int end; + const char* name; + const char* email; + QString text() const + { + //: Copyright year-year Name + return QCoreApplication::translate( "AboutData", "Copyright %1-%2 %3 <%4>
" ) + .arg( start ) + .arg( end ) + .arg( name ) + .arg( email ); + } +}; + +static constexpr const Maintainer maintainers[] = { + { 2014, 2017, "Teo Mrnjavac", "teo@kde.org" }, + { 2017, 2022, "Adriaan de Groot", "groot@kde.org" }, + { 2022, 2024, "Adriaan de Groot (community)", "groot@kde.org" }, +}; + +static QString +aboutMaintainers() +{ + QStringList s; + for ( const auto& m : maintainers ) + { + s.append( m.text() ); + } + return s.join( QString() ); +} + +static QString +substituteVersions( const QString& s ) +{ + return s.arg( CALAMARES_APPLICATION_NAME ).arg( CALAMARES_VERSION ); +} + +const QString +Calamares::aboutString() +{ + return substituteVersions( QCoreApplication::translate( "AboutData", s_header ) ) + aboutMaintainers() + + QCoreApplication::translate( "AboutData", s_footer ); +} + +const QString +Calamares::aboutStringUntranslated() +{ + return substituteVersions( QString( s_header ) ) + aboutMaintainers() + QString( s_footer ); +} diff --git a/calamares/src/libcalamares/CalamaresAbout.h b/calamares/src/libcalamares/CalamaresAbout.h new file mode 100644 index 0000000..88d4999 --- /dev/null +++ b/calamares/src/libcalamares/CalamaresAbout.h @@ -0,0 +1,31 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_CALAMARESABOUT_H +#define CALAMARES_CALAMARESABOUT_H + +#include "DllMacro.h" + +#include + +namespace Calamares +{ +/** @brief Returns an about string for the application + * + * The about string includes a header-statement, a list of maintainer + * addresses, and a thank-you to Blue Systems. There is on %-substitution + * left, where you can fill in the name of the product (e.g. to say + * "Calamares for Netrunner" or ".. for Manjaro"). + */ +DLLEXPORT const QString aboutStringUntranslated(); +/// @brief As above, but translated in the current Calamares language +DLLEXPORT const QString aboutString(); +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/CalamaresConfig.h.in b/calamares/src/libcalamares/CalamaresConfig.h.in new file mode 100644 index 0000000..65a0354 --- /dev/null +++ b/calamares/src/libcalamares/CalamaresConfig.h.in @@ -0,0 +1,34 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef CALAMARESCONFIG_H +#define CALAMARESCONFIG_H + +#define CMAKE_INSTALL_PREFIX "${CMAKE_INSTALL_PREFIX}" +#define CMAKE_INSTALL_FULL_LIBEXECDIR "${CMAKE_INSTALL_FULL_LIBEXECDIR}" +#define CMAKE_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}" +#define CMAKE_INSTALL_FULL_LIBDIR "${CMAKE_INSTALL_FULL_LIBDIR}" +#define CMAKE_INSTALL_FULL_DATADIR "${CMAKE_INSTALL_FULL_DATADIR}/calamares" +#define CMAKE_INSTALL_FULL_SYSCONFDIR "${CMAKE_INSTALL_FULL_SYSCONFDIR}" +#define CMAKE_BUILD_TYPE "${CMAKE_BUILD_TYPE}" + +/* + * These are feature-settings that affect consumers of Calamares + * libraries as well; without Python-support in the libs, for instance, + * there's no point in having a Python plugin. + * + * This list should match the one in CalamaresConfig.cmake + * which is the CMake-time side of the same configuration. + */ +#cmakedefine WITH_PYTHON +#cmakedefine WITH_PYBIND11 +#cmakedefine WITH_BOOST_PYTHON +#cmakedefine WITH_QML +#cmakedefine WITH_QT6 + +#endif // CALAMARESCONFIG_H diff --git a/calamares/src/libcalamares/CalamaresVersion.h.in b/calamares/src/libcalamares/CalamaresVersion.h.in new file mode 100644 index 0000000..5a83d46 --- /dev/null +++ b/calamares/src/libcalamares/CalamaresVersion.h.in @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: no +// SPDX-License-Identifier: CC0-1.0 +#ifndef CALAMARES_VERSION_H +#define CALAMARES_VERSION_H + +#cmakedefine CALAMARES_ORGANIZATION_NAME "${CALAMARES_ORGANIZATION_NAME}" +#cmakedefine CALAMARES_ORGANIZATION_DOMAIN "${CALAMARES_ORGANIZATION_DOMAIN}" +#cmakedefine CALAMARES_APPLICATION_NAME "${CALAMARES_APPLICATION_NAME}" +#cmakedefine CALAMARES_VERSION "${CALAMARES_VERSION_SHORT}" +#cmakedefine CALAMARES_VERSION_SHORT "${CALAMARES_VERSION_SHORT}" + +#cmakedefine CALAMARES_VERSION_MAJOR "${CALAMARES_VERSION_MAJOR}" +#cmakedefine CALAMARES_VERSION_MINOR "${CALAMARES_VERSION_MINOR}" +#cmakedefine CALAMARES_VERSION_PATCH "${CALAMARES_VERSION_PATCH}" +#cmakedefine CALAMARES_VERSION_RC "${CALAMARES_VERSION_RC}" + +#endif // CALAMARES_VERSION_H diff --git a/calamares/src/libcalamares/CalamaresVersionX.h.in b/calamares/src/libcalamares/CalamaresVersionX.h.in new file mode 100644 index 0000000..89b1bee --- /dev/null +++ b/calamares/src/libcalamares/CalamaresVersionX.h.in @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: no +// SPDX-License-Identifier: CC0-1.0 +// +// Same as CalamaresVersion.h, but with a full-git-extended VERSION +// rather than the short (vM.m.p) semantic version. +#ifndef CALAMARES_VERSION_H + +// On purpose, do not define the guard, but let CalamaresVersion.h do it +// #define CALAMARES_VERSION_H + +#include "CalamaresVersion.h" + +#undef CALAMARES_VERSION +#cmakedefine CALAMARES_VERSION "${CALAMARES_VERSION_LONG}" + +#endif // CALAMARES_VERSION_H diff --git a/calamares/src/libcalamares/CppJob.cpp b/calamares/src/libcalamares/CppJob.cpp new file mode 100644 index 0000000..45a321c --- /dev/null +++ b/calamares/src/libcalamares/CppJob.cpp @@ -0,0 +1,38 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CppJob.h" + +namespace Calamares +{ + +CppJob::CppJob( QObject* parent ) + : Job( parent ) +{ +} + + +CppJob::~CppJob() {} + + +void +CppJob::setModuleInstanceKey( const Calamares::ModuleSystem::InstanceKey& instanceKey ) +{ + m_instanceKey = instanceKey; +} + + +void +CppJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + Q_UNUSED( configurationMap ) +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/CppJob.h b/calamares/src/libcalamares/CppJob.h new file mode 100644 index 0000000..f906a0d --- /dev/null +++ b/calamares/src/libcalamares/CppJob.h @@ -0,0 +1,44 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_CPPJOB_H +#define CALAMARES_CPPJOB_H + +#include "DllMacro.h" +#include "Job.h" + +#include "modulesystem/InstanceKey.h" + +#include +#include + +namespace Calamares +{ + +class DLLEXPORT CppJob : public Job +{ + Q_OBJECT +public: + explicit CppJob( QObject* parent = nullptr ); + ~CppJob() override; + + void setModuleInstanceKey( const Calamares::ModuleSystem::InstanceKey& instanceKey ); + Calamares::ModuleSystem::InstanceKey moduleInstanceKey() const { return m_instanceKey; } + + virtual void setConfigurationMap( const QVariantMap& configurationMap ); + +protected: + Calamares::ModuleSystem::InstanceKey m_instanceKey; +}; + +} // namespace Calamares + +#endif // CALAMARES_CPPJOB_H diff --git a/calamares/src/libcalamares/DllMacro.h b/calamares/src/libcalamares/DllMacro.h new file mode 100644 index 0000000..f144d46 --- /dev/null +++ b/calamares/src/libcalamares/DllMacro.h @@ -0,0 +1,68 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef DLLMACRO_H +#define DLLMACRO_H + +#ifndef CALAMARES_EXPORT +#define CALAMARES_EXPORT __attribute__( ( visibility( "default" ) ) ) +#endif + +/* + * Mark symbols exported from Calamares non-GUI library with DLLEXPORT. + * These are the public API of libcalamares. + */ +#ifndef DLLEXPORT +#if defined( DLLEXPORT_PRO ) +#define DLLEXPORT CALAMARES_EXPORT +#else +#define DLLEXPORT +#endif +#endif + +/* + * Mark symbols exported from Calamares GUI library with DLLEXPORT. + * These are the public API of libcalamaresui. + */ +#ifndef UIDLLEXPORT +#if defined( UIDLLEXPORT_PRO ) +#define UIDLLEXPORT CALAMARES_EXPORT +#else +#define UIDLLEXPORT +#endif +#endif + +/* + * Mark symbols exported from Calamares C++ plugins with PLUGINDLLEXPORT. + * These are the public API of the libraries (generally, the plugin + * entry point) + */ +#ifndef PLUGINDLLEXPORT +#if defined( PLUGINDLLEXPORT_PRO ) +#define PLUGINDLLEXPORT CALAMARES_EXPORT +#else +#define PLUGINDLLEXPORT +#endif +#endif + +/* + * For functions that should be static in production but also need to + * be tested, use STATICTEST as linkage specifier. When built as part + * of a test, the function will be given normal linkage. + */ +#ifndef STATICTEST +#if defined( BUILD_AS_TEST ) +#define STATICTEST +#else +#define STATICTEST static +#endif +#endif + +#endif diff --git a/calamares/src/libcalamares/GlobalStorage.cpp b/calamares/src/libcalamares/GlobalStorage.cpp new file mode 100644 index 0000000..e78a1b0 --- /dev/null +++ b/calamares/src/libcalamares/GlobalStorage.cpp @@ -0,0 +1,244 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "GlobalStorage.h" + +#include "compat/Mutex.h" + +#include "utils/Logger.h" +#include "utils/Units.h" +#include "utils/Yaml.h" + +#include +#include + +using namespace Calamares::Units; + +namespace Calamares +{ + +class GlobalStorage::ReadLock : public MutexLocker +{ +public: + ReadLock( const GlobalStorage* gs ) + : MutexLocker( &gs->m_mutex ) + { + } +}; + +class GlobalStorage::WriteLock : public MutexLocker +{ +public: + WriteLock( GlobalStorage* gs ) + : MutexLocker( &gs->m_mutex ) + , m_gs( gs ) + { + } + ~WriteLock() { m_gs->changed(); } + + GlobalStorage* m_gs; +}; + +GlobalStorage::GlobalStorage( QObject* parent ) + : QObject( parent ) +{ +} + +bool +GlobalStorage::contains( const QString& key ) const +{ + ReadLock l( this ); + return m.contains( key ); +} + +int +GlobalStorage::count() const +{ + ReadLock l( this ); + return m.count(); +} + +void +GlobalStorage::insert( const QString& key, const QVariant& value ) +{ + WriteLock l( this ); + m.insert( key, value ); +} + +QStringList +GlobalStorage::keys() const +{ + ReadLock l( this ); + return m.keys(); +} + +int +GlobalStorage::remove( const QString& key ) +{ + WriteLock l( this ); + int nItems = m.remove( key ); + return nItems; +} + +void +GlobalStorage::clear() +{ + WriteLock l( this ); + m.clear(); +} + +QVariant +GlobalStorage::value( const QString& key ) const +{ + ReadLock l( this ); + return m.value( key ); +} + +void +GlobalStorage::debugDump() const +{ + ReadLock l( this ); + cDebug() << "GlobalStorage" << Logger::Pointer( this ) << m.count() << "items"; + for ( auto it = m.cbegin(); it != m.cend(); ++it ) + { + cDebug() << Logger::SubEntry << it.key() << '\t' << it.value(); + } +} + +bool +GlobalStorage::saveJson( const QString& filename ) const +{ + ReadLock l( this ); + QFile f( filename ); + if ( !f.open( QFile::WriteOnly ) ) + { + return false; + } + + f.write( QJsonDocument::fromVariant( m ).toJson() ); + f.close(); + return true; +} + +bool +GlobalStorage::loadJson( const QString& filename ) +{ + QFile f( filename ); + if ( !f.open( QFile::ReadOnly ) ) + { + return false; + } + + QJsonParseError e; + QJsonDocument d = QJsonDocument::fromJson( f.read( 1_MiB ), &e ); + if ( d.isNull() ) + { + cWarning() << filename << e.errorString(); + } + else if ( !d.isObject() ) + { + cWarning() << filename << "Not suitable JSON."; + } + else + { + WriteLock l( this ); + // Do **not** use method insert() here, because it would + // recursively lock the mutex, leading to deadlock. Also, + // that would emit changed() for each key. + auto map = d.toVariant().toMap(); + for ( auto i = map.constBegin(); i != map.constEnd(); ++i ) + { + m.insert( i.key(), *i ); + } + return true; + } + return false; +} + +bool +GlobalStorage::saveYaml( const QString& filename ) const +{ + ReadLock l( this ); + return Calamares::YAML::save( filename, m ); +} + +bool +GlobalStorage::loadYaml( const QString& filename ) +{ + bool ok = false; + auto map = Calamares::YAML::load( filename, &ok ); + if ( ok ) + { + WriteLock l( this ); + // Do **not** use method insert() here, because it would + // recursively lock the mutex, leading to deadlock. Also, + // that would emit changed() for each key. + for ( auto i = map.constBegin(); i != map.constEnd(); ++i ) + { + m.insert( i.key(), *i ); + } + return true; + } + return false; +} + +///@brief Implementation for recursively looking up dotted selector parts. +static QVariant +lookup( const QStringList& nestedKey, int index, const QVariant& v, bool& ok ) +{ + if ( !v.canConvert< QVariantMap >() ) + { + // Mismatch: we're still looking for keys, but v is not a submap + ok = false; + return {}; + } + if ( index >= nestedKey.length() ) + { + cError() << "Recursion error looking at index" << index << "of" << nestedKey; + ok = false; + return {}; + } + + const QVariantMap map = v.toMap(); + const QString& key = nestedKey.at( index ); + if ( index == nestedKey.length() - 1 ) + { + ok = map.contains( key ); + return ok ? map.value( key ) : QVariant(); + } + else + { + return lookup( nestedKey, index + 1, map.value( key ), ok ); + } +} + +QVariant +lookup( const GlobalStorage* storage, const QString& nestedKey, bool& ok ) +{ + ok = false; + if ( !storage ) + { + return {}; + } + + if ( nestedKey.contains( '.' ) ) + { + QStringList steps = nestedKey.split( '.' ); + return lookup( steps, 1, storage->value( steps.first() ), ok ); + } + else + { + ok = storage->contains( nestedKey ); + return ok ? storage->value( nestedKey ) : QVariant(); + } +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/GlobalStorage.h b/calamares/src/libcalamares/GlobalStorage.h new file mode 100644 index 0000000..37ea332 --- /dev/null +++ b/calamares/src/libcalamares/GlobalStorage.h @@ -0,0 +1,192 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef CALAMARES_GLOBALSTORAGE_H +#define CALAMARES_GLOBALSTORAGE_H + +#include "DllMacro.h" + +#include +#include +#include +#include + +namespace Calamares +{ + +/** @brief Storage for data that passes between Calamares modules. + * + * The Global Storage is global to the Calamares JobQueue and + * everything that depends on that: all of its modules use the + * same instance of the JobQueue, and so of the Global Storage. + * + * GS is used to pass data between modules; there is only convention + * about what keys are used, and individual modules should document + * what they put in to GS or what they expect to find in it. + * + * GS behaves as a basic key-value store, with a QVariantMap behind + * it. Any QVariant can be put into the storage, and the signal + * changed() is emitted when any data is modified. + * + * In general, see QVariantMap (possibly after calling data()) for details. + * + * This class is thread-safe -- most accesses go through JobQueue, which + * handles threading itself, but because modules load in parallel and can + * have asynchronous tasks like GeoIP lookups, the storage itself also + * has locking. All methods are thread-safe, use data() to make a snapshot + * copy for use outside of the thread-safe API. + */ +class DLLEXPORT GlobalStorage : public QObject +{ + Q_OBJECT +public: + /** @brief Create a GS object + * + * **Generally** there is only one GS object (hence, "global") which + * is owned by the JobQueue instance (which is a singleton). However, + * it is possible to create more GS objects. + */ + explicit GlobalStorage( QObject* parent = nullptr ); + + /** @brief Insert a key and value into the store + * + * The @p value is added to the store with key @p key. If @p key + * already exists in the store, its existing value is overwritten. + * The changed() signal is emitted regardless. + */ + void insert( const QString& key, const QVariant& value ); + /** @brief Removes a key and its value + * + * The @p key is removed from the store. If the @p key does not + * exist, nothing happens. changed() is emitted regardless. + * + * @return the number of keys remaining + */ + int remove( const QString& key ); + + /// @brief Clears all keys in this GS object + void clear(); + + /** @brief dump keys and values to the debug log + * + * All the keys and their values are written to the debug log. + * See save() for caveats: this can leak sensitive information. + */ + void debugDump() const; + + /** @brief write as JSON to the given filename + * + * The file named @p filename is overwritten with a JSON representation + * of the entire global storage (this may be structured, for instance + * if maps or lists have been inserted). + * + * No tidying, sanitization, or censoring is done -- for instance, + * the user module sets a slightly-obscured password in global storage, + * and this JSON file will contain that password in-the-only-slightly- + * obscured form. + */ + bool saveJson( const QString& filename ) const; + + /** @brief Adds the keys from the given JSON file + * + * No tidying, sanitization, or censoring is done. + * The JSON file is read and each key is added as a value to + * the global storage. The storage is not cleared first: existing + * keys will remain; keys that also occur in the JSON file are overwritten. + */ + bool loadJson( const QString& filename ); + + /** @brief write as YAML to the given filename + * + * See also save(), above. + */ + bool saveYaml( const QString& filename ) const; + + /** @brief reads settings from the given filename + * + * See also load(), above. + */ + bool loadYaml( const QString& filename ); + + /** @brief Make a complete copy of the data + * + * Provides a snapshot of the data at a given time. + */ + QVariantMap data() const { return m; } + +public Q_SLOTS: + /** @brief Does the store contain the given key? + * + * This can distinguish an explicitly-inserted QVariant() from + * a no-value-exists QVariant. See value() for details. + */ + bool contains( const QString& key ) const; + /** @brief The number of keys in the store + * + * This should be unsigned, but the underlying QMap uses signed as well. + * Equal to keys().length(), in theory. + */ + int count() const; + /** @brief The keys in the store + * + * This makes a copy of all the keys currently in the store, which + * could be used for iterating over all the values in the store. + */ + QStringList keys() const; + /** @brief Gets a value from the store + * + * If a value has been previously inserted, returns that value. + * If @p key does not exist in the store, returns a QVariant() + * (an invalid QVariant, which boolean-converts to false). Since + * QVariant() van also be inserted explicitly, use contains() + * to check for the presence of a key if you need that. + */ + QVariant value( const QString& key ) const; + +signals: + /** @brief Emitted any time the store changes + * + * Also emitted sometimes when the store does not change, e.g. + * when removing a non-existent key or inserting a value that + * is already present. + */ + void changed(); + +private: + class ReadLock; + class WriteLock; + QVariantMap m; + mutable QMutex m_mutex; +}; + + +/** @brief Gets a value from the store + * + * When @p nestedKey contains no '.' characters, equivalent + * to `gs->value(nestedKey)`. Otherwise recursively looks up + * the '.'-separated parts of @p nestedKey in successive sub-maps + * of the store, returning the value in the innermost one. + * + * Example: `lookup(gs, "branding.name")` finds the value of the + * 'name' key in the 'branding' submap of the store. + * + * Sets @p ok to @c true if a value was found. Returns the value + * as a variant. If no value is found (e.g. the key is missing + * or some prefix submap is missing) sets @p ok to @c false + * and returns an invalid QVariant. + * + * @see GlobalStorage::value + */ +DLLEXPORT QVariant lookup( const GlobalStorage* gs, const QString& nestedKey, bool& ok ); + +} // namespace Calamares + +#endif // CALAMARES_GLOBALSTORAGE_H diff --git a/calamares/src/libcalamares/Job.cpp b/calamares/src/libcalamares/Job.cpp new file mode 100644 index 0000000..902bb2b --- /dev/null +++ b/calamares/src/libcalamares/Job.cpp @@ -0,0 +1,112 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Job.h" + +namespace Calamares +{ + +JobResult::JobResult( JobResult&& rhs ) + : m_message( std::move( rhs.m_message ) ) + , m_details( std::move( rhs.m_details ) ) + , m_number( rhs.m_number ) +{ +} + +JobResult::operator bool() const +{ + return m_number == 0; +} + + +QString +JobResult::message() const +{ + return m_message; +} + + +void +JobResult::setMessage( const QString& message ) +{ + m_message = message; +} + + +QString +JobResult::details() const +{ + return m_details; +} + + +void +JobResult::setDetails( const QString& details ) +{ + m_details = details; +} + +JobResult +JobResult::ok() +{ + return JobResult( QString(), QString(), NoError ); +} + + +JobResult +JobResult::error( const QString& message, const QString& details ) +{ + return JobResult( message, details, GenericError ); +} + +JobResult +JobResult::internalError( const QString& message, const QString& details, int number ) +{ + return JobResult( message, details, number ? number : GenericError ); +} + +JobResult::JobResult( const QString& message, const QString& details, int number ) + : m_message( message ) + , m_details( details ) + , m_number( number ) +{ +} + + +Job::Job( QObject* parent ) + : QObject( parent ) +{ +} + + +Job::~Job() {} + + +int +Job::getJobWeight() const +{ + return 1; +} + + +QString +Job::prettyDescription() const +{ + return QString(); +} + + +QString +Job::prettyStatusMessage() const +{ + return QString(); +} + + +} // namespace Calamares diff --git a/calamares/src/libcalamares/Job.h b/calamares/src/libcalamares/Job.h new file mode 100644 index 0000000..931029a --- /dev/null +++ b/calamares/src/libcalamares/Job.h @@ -0,0 +1,175 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_JOB_H +#define CALAMARES_JOB_H + +#include "DllMacro.h" + +#include +#include +#include + +namespace Calamares +{ + +class DLLEXPORT JobResult +{ +public: + /** @brief Distinguish classes of errors + * + * All "ok result" have errorCode 0 (NoError). + * Errors returned from job execution have values < 0. + * Errors before job execution, or not returned by the job execution + * itself, have values > 0. + */ + enum + { + NoError = 0, + GenericError = -1, + PythonUncaughtException = 1, + InvalidConfiguration = 2, + MissingRequirements = 3, + }; + + // Can't copy, but you can keep a temporary + JobResult( const JobResult& rhs ) = delete; + JobResult( JobResult&& rhs ); + + virtual ~JobResult() {} + + /** @brief Is this JobResult a success? + * + * Equivalent to errorCode() == 0, see succeeded(). + */ + virtual operator bool() const; + + virtual QString message() const; + virtual void setMessage( const QString& message ); + + virtual QString details() const; + virtual void setDetails( const QString& details ); + + int errorCode() const { return m_number; } + /** @brief Is this JobResult a success? + * + * Equivalent to errorCode() == 0. + */ + bool succeeded() const { return this->operator bool(); } + + /// @brief an "ok status" result + static JobResult ok(); + /** @brief an "error" result resulting from the execution of the job + * + * The error code is set to GenericError. + */ + static JobResult error( const QString& message, const QString& details = QString() ); + /** @brief an "internal error" meaning the job itself has a problem (usually for python) + * + * Pass in a suitable error code; using 0 (which would normally mean "ok") instead + * gives you a GenericError code. + */ + static JobResult internalError( const QString& message, const QString& details, int errorCode ); + +protected: + explicit JobResult( const QString& message, const QString& details, int errorCode ); + +private: + QString m_message; + QString m_details; + int m_number; +}; + +class DLLEXPORT Job : public QObject +{ + Q_OBJECT +public: + explicit Job( QObject* parent = nullptr ); + ~Job() override; + + /** @brief The job's (relative) weight. + * + * The default implementation returns 1, which gives all jobs + * the same weight, so they advance the overall progress the same + * amount. This is nonsense, since some jobs take much longer than + * others; it's up to the individual jobs to say something about + * how much work is (relatively) done. + * + * Since jobs are caused by **modules** from the sequence, the + * overall weight of the module is taken into account: its weight + * is divided among the jobs based on each jobs relative weight. + * This can be used in a module that runs a bunch of jobs to indicate + * which of the jobs is "heavy" and which is not. + */ + virtual int getJobWeight() const; + + /** @brief The human-readable name of this job + * + * This should be a very short statement of what the job does. + * For status and state information, see prettyStatusMessage(). + * + * The job's name may be similar to the status message, but this is + * a name, and should not be an active verb phrase. The translation + * should use context @c \@label . + * + * The name of the job is used as a **fallback** when the status + * or descriptions are empty. If a job has no implementation of + * those methods, it is OK to use other contexts, but it may look + * strange in some places in the UI. + */ + virtual QString prettyName() const = 0; + + /** @brief a longer human-readable description of what the job will do + * + * This **may** be used by view steps to fill in the summary + * messages for the summary page; at present, only the *partition* + * module does so. + * + * The default implementation returns an empty string. + * + * The translation should use context @c \@title . + */ + virtual QString prettyDescription() const; + + /** @brief A human-readable status for progress reporting + * + * This is called from the JobQueue when progress is made, and should + * return a not-too-long description of the job's status. This + * is made visible in the progress bar of the execution view step. + * + * The job's status should say **what** the job is doing. It should be in + * present active tense. Typically the translation uses tr() context + * @c \@status . See prettyName() for examples. + */ + virtual QString prettyStatusMessage() const; + + virtual JobResult exec() = 0; + + bool isEmergency() const { return m_emergency; } + void setEmergency( bool e ) { m_emergency = e; } + +signals: + /** @brief Signals that the job has made progress + * + * The parameter @p percent should be between 0 (0%) and 1 (100%). + * Values outside of this range will be clamped. + */ + void progress( qreal percent ); + +private: + bool m_emergency = false; +}; + +using job_ptr = QSharedPointer< Job >; +using JobList = QList< job_ptr >; + +} // namespace Calamares + +#endif // CALAMARES_JOB_H diff --git a/calamares/src/libcalamares/JobExample.cpp b/calamares/src/libcalamares/JobExample.cpp new file mode 100644 index 0000000..4852d3a --- /dev/null +++ b/calamares/src/libcalamares/JobExample.cpp @@ -0,0 +1,33 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "JobExample.h" + +namespace Calamares +{ + +QString +NamedJob::prettyName() const +{ + return tr( "Example job (%1)" ).arg( m_name ); +} + +JobResult +GoodJob::exec() +{ + return JobResult::ok(); +} + +JobResult +FailJob::exec() +{ + return JobResult::error( tr( "Job failed (%1)" ).arg( m_name ), + tr( "Programmed job failure was explicitly requested." ) ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/JobExample.h b/calamares/src/libcalamares/JobExample.h new file mode 100644 index 0000000..e3506fe --- /dev/null +++ b/calamares/src/libcalamares/JobExample.h @@ -0,0 +1,69 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_JOB_EXAMPLE_H +#define CALAMARES_JOB_EXAMPLE_H + +#include "Job.h" + +namespace Calamares +{ + +/** @brief A Job with a name + * + * This includes a default implementation of prettyName(), + * but is only used as a base for FailJob and GoodJob, + * which are support / bogus classes. + */ +class DLLEXPORT NamedJob : public Job +{ + Q_OBJECT +public: + explicit NamedJob( const QString& name, QObject* parent = nullptr ) + : Job( parent ) + , m_name( name ) + { + } + + virtual QString prettyName() const override; + +protected: + const QString m_name; +}; + +/// @brief Job does nothing, always succeeds +class DLLEXPORT GoodJob : public NamedJob +{ + Q_OBJECT +public: + explicit GoodJob( const QString& name, QObject* parent = nullptr ) + : NamedJob( name, parent ) + { + } + + virtual JobResult exec() override; +}; + + +/// @brief Job does nothing, always fails +class DLLEXPORT FailJob : public NamedJob +{ + Q_OBJECT +public: + explicit FailJob( const QString& name, QObject* parent = nullptr ) + : NamedJob( name, parent ) + { + } + + virtual JobResult exec() override; +}; + +} // namespace Calamares + +#endif // CALAMARES_JOB_EXAMPLE_H diff --git a/calamares/src/libcalamares/JobQueue.cpp b/calamares/src/libcalamares/JobQueue.cpp new file mode 100644 index 0000000..0028992 --- /dev/null +++ b/calamares/src/libcalamares/JobQueue.cpp @@ -0,0 +1,618 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "JobQueue.h" + +#include "CalamaresConfig.h" +#include "GlobalStorage.h" +#include "Job.h" +#include "compat/Mutex.h" +#include "utils/Logger.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include // for close() + +namespace +{ +// This power-management code is largely cribbed from KDE Discover, +// https://invent.kde.org/plasma/discover/-/blob/master/discover/PowerManagementInterface.cpp +// +// Upstream license text says: +// +// SPDX-FileCopyrightText: 2019 (c) Matthieu Gallien +// SPDX-License-Identifier: LGPL-3.0-or-later + + +/** @brief Class to manage sleep / suspend on inactivity (via fd.o Inhibit service on the user bus) + * + * Create an object of this class on the heap. Call inhibitSleep() + * to (try to) stop system sleep / suspend. Call uninhibitSleep() + * when the object is no longer needed. The object self-deletes + * after uninhibitSleep() completes. + */ +class PowerManagementInterface : public QObject +{ + Q_OBJECT +public: + PowerManagementInterface( QObject* parent = nullptr ); + ~PowerManagementInterface() override; + +public Q_SLOTS: + void inhibitSleep(); + void uninhibitSleep(); + +private Q_SLOTS: + void hostSleepInhibitChanged(); + void inhibitDBusCallFinished( QDBusPendingCallWatcher* aWatcher ); + void uninhibitDBusCallFinished( QDBusPendingCallWatcher* aWatcher ); + +private: + uint m_inhibitSleepCookie = 0; + bool m_inhibitedSleep = false; +}; + +PowerManagementInterface::PowerManagementInterface( QObject* parent ) + : QObject( parent ) +{ + auto sessionBus = QDBusConnection::sessionBus(); + + sessionBus.connect( QStringLiteral( "org.freedesktop.PowerManagement.Inhibit" ), + QStringLiteral( "/org/freedesktop/PowerManagement/Inhibit" ), + QStringLiteral( "org.freedesktop.PowerManagement.Inhibit" ), + QStringLiteral( "HasInhibitChanged" ), + this, + SLOT( hostSleepInhibitChanged() ) ); +} + +PowerManagementInterface::~PowerManagementInterface() = default; + +void +PowerManagementInterface::hostSleepInhibitChanged() +{ + // We don't actually care +} + +void +PowerManagementInterface::inhibitDBusCallFinished( QDBusPendingCallWatcher* aWatcher ) +{ + QDBusPendingReply< uint > reply = *aWatcher; + if ( reply.isError() ) + { + cError() << "Could not inhibit sleep:" << reply.error(); + // m_inhibitedSleep = false; // unchanged + } + else + { + m_inhibitSleepCookie = reply.argumentAt< 0 >(); + m_inhibitedSleep = true; + cDebug() << "Sleep inhibited, cookie" << m_inhibitSleepCookie; + } + aWatcher->deleteLater(); +} + +void +PowerManagementInterface::uninhibitDBusCallFinished( QDBusPendingCallWatcher* aWatcher ) +{ + QDBusPendingReply<> reply = *aWatcher; + if ( reply.isError() ) + { + cError() << "Could not uninhibit sleep:" << reply.error(); + } + else + { + m_inhibitedSleep = false; + m_inhibitSleepCookie = 0; + cDebug() << "Sleep uninhibited."; + } + aWatcher->deleteLater(); + this->deleteLater(); +} + +void +PowerManagementInterface::inhibitSleep() +{ + if ( m_inhibitedSleep ) + { + cDebug() << "Sleep is already inhibited."; + return; + } + + auto sessionBus = QDBusConnection::sessionBus(); + auto inhibitCall = QDBusMessage::createMethodCall( QStringLiteral( "org.freedesktop.PowerManagement.Inhibit" ), + QStringLiteral( "/org/freedesktop/PowerManagement/Inhibit" ), + QStringLiteral( "org.freedesktop.PowerManagement.Inhibit" ), + QStringLiteral( "Inhibit" ) ); + inhibitCall.setArguments( { { tr( "Calamares" ) }, { tr( "Installation in progress", "@status" ) } } ); + + auto asyncReply = sessionBus.asyncCall( inhibitCall ); + auto* replyWatcher = new QDBusPendingCallWatcher( asyncReply, this ); + QObject::connect( + replyWatcher, &QDBusPendingCallWatcher::finished, this, &PowerManagementInterface::inhibitDBusCallFinished ); +} + +void +PowerManagementInterface::uninhibitSleep() +{ + if ( !m_inhibitedSleep ) + { + cDebug() << "Sleep was never inhibited."; + this->deleteLater(); + return; + } + + auto sessionBus = QDBusConnection::sessionBus(); + auto uninhibitCall = QDBusMessage::createMethodCall( QStringLiteral( "org.freedesktop.PowerManagement.Inhibit" ), + QStringLiteral( "/org/freedesktop/PowerManagement/Inhibit" ), + QStringLiteral( "org.freedesktop.PowerManagement.Inhibit" ), + QStringLiteral( "UnInhibit" ) ); + uninhibitCall.setArguments( { { m_inhibitSleepCookie } } ); + + auto asyncReply = sessionBus.asyncCall( uninhibitCall ); + auto replyWatcher = new QDBusPendingCallWatcher( asyncReply, this ); + QObject::connect( + replyWatcher, &QDBusPendingCallWatcher::finished, this, &PowerManagementInterface::uninhibitDBusCallFinished ); +} + + +/** @brief Class to manage sleep / suspend on inactivity (via logind/CK2 service on the system bus) + * + * Create an object of this class on the heap. Call inhibitSleep() + * to (try to) stop system sleep / suspend. Call uninhibitSleep() + * when the object is no longer needed. The object self-deletes + * after uninhibitSleep() completes. + */ +class LoginManagerInterface : public QObject +{ + Q_OBJECT +public: + static LoginManagerInterface* makeForRegisteredService( QObject* parent = nullptr ); + ~LoginManagerInterface() override; + +public Q_SLOTS: + void inhibitSleep(); + void uninhibitSleep(); + +private Q_SLOTS: + void inhibitDBusCallFinished( QDBusPendingCallWatcher* aWatcher ); + +private: + enum class Service + { + Logind, + ConsoleKit, + }; + LoginManagerInterface( Service service, QObject* parent = nullptr ); + + int m_inhibitFd = -1; + Service m_service; +}; + +LoginManagerInterface* +LoginManagerInterface::makeForRegisteredService( QObject* parent ) +{ + if ( QDBusConnection::systemBus().interface()->isServiceRegistered( QStringLiteral( "org.freedesktop.login1" ) ) ) + { + return new LoginManagerInterface( Service::Logind, parent ); + } + else if ( QDBusConnection::systemBus().interface()->isServiceRegistered( + QStringLiteral( "org.freedesktop.ConsoleKit" ) ) ) + { + return new LoginManagerInterface( Service::ConsoleKit, parent ); + } + else + { + return nullptr; + } +} + +LoginManagerInterface::LoginManagerInterface( Service service, QObject* parent ) + : QObject( parent ) + , m_service( service ) +{ +} + +LoginManagerInterface::~LoginManagerInterface() = default; + +void +LoginManagerInterface::inhibitDBusCallFinished( QDBusPendingCallWatcher* aWatcher ) +{ + QDBusPendingReply< uint > reply = *aWatcher; + if ( reply.isError() ) + { + cError() << "Could not inhibit sleep:" << reply.error(); + // m_inhibitFd = -1; // unchanged + } + else + { + m_inhibitFd = reply.argumentAt< 0 >(); + cDebug() << "Sleep inhibited, file descriptor" << m_inhibitFd; + } + aWatcher->deleteLater(); +} + +void +LoginManagerInterface::inhibitSleep() +{ + if ( m_inhibitFd == -1 ) + { + cDebug() << "Sleep is already inhibited."; + return; + } + + auto systemBus = QDBusConnection::systemBus(); + QDBusMessage inhibitCall; + + if ( m_service == Service::Logind ) + { + inhibitCall = QDBusMessage::createMethodCall( QStringLiteral( "org.freedesktop.login1" ), + QStringLiteral( "/org/freedesktop/login1" ), + QStringLiteral( "org.freedesktop.login1.Manager" ), + QStringLiteral( "Inhibit" ) ); + } + else if ( m_service == Service::ConsoleKit ) + { + inhibitCall = QDBusMessage::createMethodCall( QStringLiteral( "org.freedesktop.ConsoleKit" ), + QStringLiteral( "/org/freedesktop/ConsoleKit/Manager" ), + QStringLiteral( "org.freedesktop.ConsoleKit.Manager" ), + QStringLiteral( "Inhibit" ) ); + } + else + { + cError() << "System sleep interface not supported."; + return; + } + + inhibitCall.setArguments( + { { "sleep:shutdown" }, { tr( "Calamares" ) }, { tr( "Installation in progress", "@status" ) }, { "block" } } ); + + auto asyncReply = systemBus.asyncCall( inhibitCall ); + auto* replyWatcher = new QDBusPendingCallWatcher( asyncReply, this ); + QObject::connect( + replyWatcher, &QDBusPendingCallWatcher::finished, this, &LoginManagerInterface::inhibitDBusCallFinished ); +} + + +void +LoginManagerInterface::uninhibitSleep() +{ + if ( m_inhibitFd == -1 ) + { + cDebug() << "Sleep was never inhibited."; + this->deleteLater(); + return; + } + + if ( close( m_inhibitFd ) != 0 ) + { + cError() << "Could not uninhibit sleep:" << strerror( errno ); + } + this->deleteLater(); +} + +} // namespace + +namespace Calamares +{ +SleepInhibitor::SleepInhibitor() +{ + // Create a LoginManagerInterface object with intentionally no parent + // so it is not destroyed along with this. Instead, when this + // is destroyed, **start** the uninhibit-sleep call which will (later) + // destroy the LoginManagerInterface object. + if ( auto* l = LoginManagerInterface::makeForRegisteredService( nullptr ) ) + { + l->inhibitSleep(); + connect( this, &QObject::destroyed, l, &LoginManagerInterface::uninhibitSleep ); + } + // If no login manager service was present, try the same thing + // with PowerManagementInterface. + else + { + auto* p = new PowerManagementInterface( nullptr ); + p->inhibitSleep(); + connect( this, &QObject::destroyed, p, &PowerManagementInterface::uninhibitSleep ); + } +} + +SleepInhibitor::~SleepInhibitor() = default; + +struct WeightedJob +{ + /** @brief Cumulative weight **before** this job starts + * + * This is calculated as jobs come in. + */ + qreal cumulative = 0.0; + /** @brief Weight of the job within the module's jobs + * + * When a list of jobs is added from a particular module, + * the jobs are weighted relative to that module's overall weight + * **and** the other jobs in the list, so that each job + * gets its share: + * ( job-weight / total-job-weight ) * module-weight + */ + qreal weight = 0.0; + + job_ptr job; +}; +using WeightedJobList = QList< WeightedJob >; + +class JobThread : public QThread +{ + Q_OBJECT +public: + JobThread( JobQueue* queue ) + : QThread( queue ) + , m_queue( queue ) + , m_jobIndex( 0 ) + { + } + + ~JobThread() override; + + void finalize() + { + Q_ASSERT( m_runningJobs->isEmpty() ); + Calamares::MutexLocker qlock( &m_enqueMutex ); + Calamares::MutexLocker rlock( &m_runMutex ); + std::swap( m_runningJobs, m_queuedJobs ); + m_overallQueueWeight + = m_runningJobs->isEmpty() ? 0.0 : ( m_runningJobs->last().cumulative + m_runningJobs->last().weight ); + if ( m_overallQueueWeight < 1 ) + { + m_overallQueueWeight = 1.0; + } + + cDebug() << "There are" << m_runningJobs->count() << "jobs, total weight" << m_overallQueueWeight; + int c = 0; + for ( const auto& j : *m_runningJobs ) + { + cDebug() << Logger::SubEntry << "Job" << ( c + 1 ) << j.job->prettyName() << "+wt" << j.weight << "tot.wt" + << ( j.cumulative + j.weight ); + c++; + } + } + + void enqueue( int moduleWeight, const JobList& jobs ) + { + Calamares::MutexLocker qlock( &m_enqueMutex ); + + qreal cumulative + = m_queuedJobs->isEmpty() ? 0.0 : ( m_queuedJobs->last().cumulative + m_queuedJobs->last().weight ); + + qreal totalJobWeight + = std::accumulate( jobs.cbegin(), + jobs.cend(), + qreal( 0.0 ), + []( qreal total, const job_ptr& j ) { return total + j->getJobWeight(); } ); + if ( totalJobWeight < 1 ) + { + totalJobWeight = 1.0; + } + + for ( const auto& j : jobs ) + { + qreal jobContribution = ( j->getJobWeight() / totalJobWeight ) * moduleWeight; + m_queuedJobs->append( WeightedJob { cumulative, jobContribution, j } ); + cumulative += jobContribution; + } + } + + void run() override + { + Calamares::MutexLocker rlock( &m_runMutex ); + bool failureEncountered = false; + QString message; ///< Filled in with errors + QString details; + + Logger::Once o; + m_jobIndex = 0; + for ( const auto& jobitem : *m_runningJobs ) + { + if ( failureEncountered && !jobitem.job->isEmergency() ) + { + cDebug() << o << "Skipping non-emergency job" << jobitem.job->prettyName(); + } + else + { + cDebug() << o << "Starting" << ( failureEncountered ? "EMERGENCY JOB" : "job" ) + << jobitem.job->prettyName() << '(' << ( m_jobIndex + 1 ) << '/' << m_runningJobs->count() + << ')'; + o.refresh(); // So next time it shows the function header again + emitProgress( 0.0 ); // 0% for *this job* + connect( jobitem.job.data(), &Job::progress, this, &JobThread::emitProgress ); + auto result = jobitem.job->exec(); + if ( !failureEncountered && !result ) + { + // so this is the first failure + failureEncountered = true; + message = result.message(); + details = result.details(); + } + QThread::msleep( 16 ); // Very brief rest before reporting the job as complete + emitProgress( 1.0 ); // 100% for *this job* + } + m_jobIndex++; + } + if ( failureEncountered ) + { + QMetaObject::invokeMethod( + m_queue, "failed", Qt::QueuedConnection, Q_ARG( QString, message ), Q_ARG( QString, details ) ); + } + else + { + emitProgress( 1.0 ); + } + m_runningJobs->clear(); + QMetaObject::invokeMethod( m_queue, "finish", Qt::QueuedConnection ); + } + + /** @brief The names of the queued (not running!) jobs. + */ + QStringList queuedJobs() const + { + Calamares::MutexLocker qlock( &m_enqueMutex ); + QStringList l; + l.reserve( m_queuedJobs->count() ); + for ( const auto& j : *m_queuedJobs ) + { + l << j.job->prettyName(); + } + return l; + } + +private: + /* This is called **only** from run(), while m_runMutex is + * already locked, so we can use the m_runningJobs member safely. + */ + void emitProgress( qreal percentage ) const + { + percentage = qBound( 0.0, percentage, 1.0 ); + + QString message; + qreal progress = 0.0; + if ( m_jobIndex < m_runningJobs->count() ) + { + const auto& jobitem = m_runningJobs->at( m_jobIndex ); + progress = ( jobitem.cumulative + jobitem.weight * percentage ) / m_overallQueueWeight; + message = jobitem.job->prettyStatusMessage(); + // In progress reports at the start of a job (e.g. when the queue + // starts the job, or if the job itself reports 0.0) be more + // accepting in what gets reported: jobs with no status fall + // back to description and name, whichever is non-empty. + // + // Later calls (e.g. when percentage > 0) use the status unchanged. + // It may be empty, but the ExecutionViewStep knows about empty + // status messages and does not update the text in that case. + // + // This means that a Job can implement just prettyName() and get + // a reasonable "status" message which will update only once. + if ( percentage == 0.0 && message.isEmpty() ) + { + message = jobitem.job->prettyDescription(); + if ( message.isEmpty() ) + { + message = jobitem.job->prettyName(); + } + } + } + else + { + progress = 1.0; + message = tr( "Done" ); + } + QMetaObject::invokeMethod( + m_queue, "progress", Qt::QueuedConnection, Q_ARG( qreal, progress ), Q_ARG( QString, message ) ); + } + + mutable QMutex m_runMutex; + mutable QMutex m_enqueMutex; + + std::unique_ptr< WeightedJobList > m_runningJobs = std::make_unique< WeightedJobList >(); + std::unique_ptr< WeightedJobList > m_queuedJobs = std::make_unique< WeightedJobList >(); + + JobQueue* m_queue; + int m_jobIndex = 0; ///< Index into m_runningJobs + qreal m_overallQueueWeight = 0.0; ///< cumulation when **all** the jobs are done +}; + +JobThread::~JobThread() {} + + +JobQueue* JobQueue::s_instance = nullptr; + +JobQueue* +JobQueue::instance() +{ + if ( !s_instance ) + { + cWarning() << "Getting nullptr JobQueue instance."; + } + return s_instance; +} + + +JobQueue::JobQueue( QObject* parent ) + : QObject( parent ) + , m_thread( new JobThread( this ) ) + , m_storage( new GlobalStorage( this ) ) +{ + Q_ASSERT( !s_instance ); + s_instance = this; +} + + +JobQueue::~JobQueue() +{ + if ( m_thread->isRunning() ) + { + m_thread->terminate(); + if ( !m_thread->wait( 300 ) ) + { + cError() << "Could not terminate job thread (expect a crash now)."; + } + delete m_thread; + } + + delete m_storage; + s_instance = nullptr; +} + + +void +JobQueue::start() +{ + Q_ASSERT( !m_thread->isRunning() ); + m_thread->finalize(); + m_finished = false; + m_thread->start(); + + auto* inhibitor = new PowerManagementInterface( this ); + inhibitor->inhibitSleep(); + connect( this, &JobQueue::finished, inhibitor, &PowerManagementInterface::uninhibitSleep ); +} + + +void +JobQueue::enqueue( int moduleWeight, const JobList& jobs ) +{ + Q_ASSERT( !m_thread->isRunning() ); + m_thread->enqueue( moduleWeight, jobs ); + emit queueChanged( m_thread->queuedJobs() ); +} + +void +JobQueue::finish() +{ + m_finished = true; + emit finished(); + emit queueChanged( m_thread->queuedJobs() ); +} + +GlobalStorage* +JobQueue::globalStorage() const +{ + return m_storage; +} + +} // namespace Calamares + +#include "utils/moc-warnings.h" + +#include "JobQueue.moc" diff --git a/calamares/src/libcalamares/JobQueue.h b/calamares/src/libcalamares/JobQueue.h new file mode 100644 index 0000000..593bb06 --- /dev/null +++ b/calamares/src/libcalamares/JobQueue.h @@ -0,0 +1,122 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_JOBQUEUE_H +#define CALAMARES_JOBQUEUE_H + +#include "DllMacro.h" +#include "Job.h" + +#include + +namespace Calamares +{ +class GlobalStorage; +class JobThread; + +///@brief RAII class to suppress sleep / suspend during its lifetime +class DLLEXPORT SleepInhibitor : public QObject +{ + Q_OBJECT +public: + SleepInhibitor(); + ~SleepInhibitor() override; +}; + +class DLLEXPORT JobQueue : public QObject +{ + Q_OBJECT +public: + explicit JobQueue( QObject* parent = nullptr ); + ~JobQueue() override; + + /** @brief Returns the most-recently-created instance. + * + * It is possible for instance() to be @c nullptr, since you must + * call the constructor explicitly first. + */ + static JobQueue* instance(); + /* @brief Returns the GlobalStorage object for the instance. + * + * It is possible for instanceGlobalStorage() to be @c nullptr, + * since there might not be an instance to begin with. + */ + static GlobalStorage* instanceGlobalStorage() + { + auto* jq = instance(); + return jq ? jq->globalStorage() : nullptr; + } + + GlobalStorage* globalStorage() const; + + /** @brief Queues up jobs from a single module source + * + * The total weight of the jobs is spread out to fill the weight + * of the module. + */ + void enqueue( int moduleWeight, const JobList& jobs ); + /** @brief Starts all the jobs that are enqueued. + * + * After this, isRunning() returns @c true until + * finished() is emitted. + */ + void start(); + + bool isRunning() const { return !m_finished; } + +signals: + /** @brief Report progress of the whole queue, with a status message + * + * The @p percent is a value between 0.0 and 1.0 (100%) of the + * overall queue progress (not of the current job), while + * @p prettyName is the status message from the job -- often + * just the name of the job, but some jobs include more information. + */ + void progress( qreal percent, const QString& prettyName ); + /** @brief Indicate that the queue is empty, after calling start() + * + * Emitted when the queue empties. The queue may also emit + * failed(), if something went wrong, but finished() is always + * the last one. + */ + void finished(); + /** @brief A job in the queue failed. + * + * Contains the (already-translated) text from the job describing + * the failure. + */ + void failed( const QString& message, const QString& details ); + + /** @brief Reports the names of jobs in the queue. + * + * When jobs are added via enqueue(), or when the queue otherwise + * changes, the **names** of the jobs are reported. This is + * primarily for debugging purposes. + */ + void queueChanged( const QStringList& jobNames ); + +public Q_SLOTS: + /** @brief Implementation detail + * + * This is a private implementation detail for the job thread, + * which should not be called by other core. + */ + void finish(); + +private: + static JobQueue* s_instance; + + JobThread* m_thread; + GlobalStorage* m_storage; + bool m_finished = true; ///< Initially, not running +}; + +} // namespace Calamares + +#endif // CALAMARES_JOBQUEUE_H diff --git a/calamares/src/libcalamares/ProcessJob.cpp b/calamares/src/libcalamares/ProcessJob.cpp new file mode 100644 index 0000000..3d938d5 --- /dev/null +++ b/calamares/src/libcalamares/ProcessJob.cpp @@ -0,0 +1,65 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ProcessJob.h" + +#include "utils/CommandList.h" +#include "utils/Logger.h" + +#include + +namespace Calamares +{ + +ProcessJob::ProcessJob( const QString& command, + const QString& workingPath, + bool runInChroot, + std::chrono::seconds secondsTimeout, + QObject* parent ) + : Job( parent ) + , m_command( command ) + , m_workingPath( workingPath ) + , m_runInChroot( runInChroot ) + , m_timeoutSec( secondsTimeout ) +{ +} + +ProcessJob::~ProcessJob() = default; + +QString +ProcessJob::prettyName() const +{ + return ( m_runInChroot ? QStringLiteral( "Run command '%1' in target system" ) + : QStringLiteral( "Run command '%1'" ) ) + .arg( m_command ); +} + +QString +ProcessJob::prettyStatusMessage() const +{ + if ( m_runInChroot ) + { + return tr( "Running command %1 in target system…", "@status" ).arg( m_command ); + } + else + { + return tr( "Running command %1…", "@status" ).arg( m_command ); + } +} + +JobResult +ProcessJob::exec() +{ + Calamares::CommandList l( m_runInChroot, m_timeoutSec ); + l.push_back( Calamares::CommandLine { m_command } ); + return l.run(); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/ProcessJob.h b/calamares/src/libcalamares/ProcessJob.h new file mode 100644 index 0000000..2e44ba3 --- /dev/null +++ b/calamares/src/libcalamares/ProcessJob.h @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PROCESSJOB_H +#define CALAMARES_PROCESSJOB_H + +#include "DllMacro.h" +#include "Job.h" + +#include + +namespace Calamares +{ + +class DLLEXPORT ProcessJob : public Job +{ + Q_OBJECT +public: + explicit ProcessJob( const QString& command, + const QString& workingPath, + bool runInChroot = false, + std::chrono::seconds secondsTimeout = std::chrono::seconds( 30 ), + QObject* parent = nullptr ); + ~ProcessJob() override; + + QString prettyName() const override; + QString prettyStatusMessage() const override; + JobResult exec() override; + +private: + QString m_command; + QString m_workingPath; + bool m_runInChroot; + std::chrono::seconds m_timeoutSec; +}; + +} // namespace Calamares + +#endif // CALAMARES_PROCESSJOB_H diff --git a/calamares/src/libcalamares/Settings.cpp b/calamares/src/libcalamares/Settings.cpp new file mode 100644 index 0000000..32a6e10 --- /dev/null +++ b/calamares/src/libcalamares/Settings.cpp @@ -0,0 +1,483 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Gabriel Craciunescu + * SPDX-FileCopyrightText: 2019 Dominic Hayes + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Settings.h" + +#include "CalamaresConfig.h" +#include "compat/Variant.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" +#include "utils/Yaml.h" + +#include +#include +#include + +static bool +hasValue( const YAML::Node& v ) +{ + return v.IsDefined() && !v.IsNull(); +} + +/** @brief Helper function to grab a QString out of the config, and to warn if not present. */ +static QString +requireString( const ::YAML::Node& config, const char* key ) +{ + auto v = config[ key ]; + if ( hasValue( v ) ) + { + return QString::fromStdString( v.as< std::string >() ); + } + else + { + cWarning() << Logger::SubEntry << "Required settings.conf key" << key << "is missing."; + return QString(); + } +} + +/** @brief Helper function to grab a bool out of the config, and to warn if not present. */ +static bool +requireBool( const ::YAML::Node& config, const char* key, bool d ) +{ + auto v = config[ key ]; + if ( hasValue( v ) ) + { + return v.as< bool >(); + } + else + { + cWarning() << Logger::SubEntry << "Required settings.conf key" << key << "is missing."; + return d; + } +} + +namespace Calamares +{ + +InstanceDescription::InstanceDescription( const Calamares::ModuleSystem::InstanceKey& key ) + : m_instanceKey( key ) + , m_weight( -1 ) +{ + if ( !isValid() ) + { + m_weight = 0; + } + else + { + m_configFileName = key.module() + QStringLiteral( ".conf" ); + } +} + +InstanceDescription +InstanceDescription::fromSettings( const QVariantMap& m ) +{ + InstanceDescription r( + Calamares::ModuleSystem::InstanceKey( m.value( "module" ).toString(), m.value( "id" ).toString() ) ); + if ( r.isValid() ) + { + if ( m.value( "weight" ).isValid() ) + { + int w = qBound( 1, m.value( "weight" ).toInt(), 100 ); + r.m_weight = w; + } + + QString c = m.value( "config" ).toString(); + if ( !c.isEmpty() ) + { + r.m_configFileName = c; + } + } + return r; +} + +Settings* Settings::s_instance = nullptr; + +Settings* +Settings::instance() +{ + if ( !s_instance ) + { + cWarning() << "Getting nullptr Settings instance."; + } + return s_instance; +} + +static void +interpretModulesSearch( const bool debugMode, const QStringList& rawPaths, QStringList& output ) +{ + for ( const auto& path : rawPaths ) + { + if ( path == "local" ) + { + // If we're running in debug mode, we assume we might also be + // running from the build dir, so we add a maximum priority + // module search path in the build dir. + if ( debugMode ) + { + QString buildDirModules + = QDir::current().absolutePath() + QDir::separator() + "src" + QDir::separator() + "modules"; + if ( QDir( buildDirModules ).exists() ) + { + output.append( buildDirModules ); + } + } + + // Install path is set in CalamaresAddPlugin.cmake + output.append( Calamares::systemLibDir().absolutePath() + QDir::separator() + "calamares" + + QDir::separator() + "modules" ); + } + else + { + QDir d( path ); + if ( d.exists() && d.isReadable() ) + { + output.append( d.absolutePath() ); + } + else + { + cDebug() << Logger::SubEntry << "module-search entry non-existent" << path; + } + } + } +} + +static void +interpretInstances( const ::YAML::Node& node, Settings::InstanceDescriptionList& customInstances ) +{ + // Parse the custom instances section + if ( node ) + { + QVariant instancesV = Calamares::YAML::toVariant( node ).toList(); + if ( typeOf( instancesV ) == ListVariantType ) + { + const auto instances = instancesV.toList(); + for ( const QVariant& instancesVListItem : instances ) + { + if ( typeOf( instancesVListItem ) != MapVariantType ) + { + continue; + } + auto description = InstanceDescription::fromSettings( instancesVListItem.toMap() ); + if ( !description.isValid() ) + { + cWarning() << "Invalid entry in *instances*" << instancesVListItem; + } + // Append it **anyway**, since this will bail out after Settings is constructed + customInstances.append( description ); + } + } + } +} + +static void +interpretSequence( const ::YAML::Node& node, Settings::ModuleSequence& moduleSequence ) +{ + // Parse the modules sequence section + if ( node ) + { + QVariant sequenceV = Calamares::YAML::toVariant( node ); + if ( typeOf( sequenceV ) != ListVariantType ) + { + throw ::YAML::Exception( ::YAML::Mark(), "sequence key does not have a list-value" ); + } + + const auto sequence = sequenceV.toList(); + for ( const QVariant& sequenceVListItem : sequence ) + { + if ( typeOf( sequenceVListItem ) != MapVariantType ) + { + continue; + } + QString thisActionS = sequenceVListItem.toMap().firstKey(); + ModuleSystem::Action thisAction; + if ( thisActionS == "show" ) + { + thisAction = ModuleSystem::Action::Show; + } + else if ( thisActionS == "exec" ) + { + thisAction = ModuleSystem::Action::Exec; + } + else + { + cDebug() << "Unknown action in *sequence*" << thisActionS; + continue; + } + + QStringList thisActionRoster = sequenceVListItem.toMap().value( thisActionS ).toStringList(); + Calamares::ModuleSystem::InstanceKeyList roster; + roster.reserve( thisActionRoster.count() ); + for ( const auto& s : thisActionRoster ) + { + auto instanceKey = Calamares::ModuleSystem::InstanceKey::fromString( s ); + if ( !instanceKey.isValid() ) + { + cWarning() << "Invalid instance in *sequence*" << s; + } + roster.append( instanceKey ); + } + moduleSequence.append( qMakePair( thisAction, roster ) ); + } + } + else + { + throw ::YAML::Exception( ::YAML::Mark(), "sequence key is missing" ); + } +} + +Settings::Settings( bool debugMode ) + : QObject() + , m_debug( debugMode ) + , m_doChroot( true ) + , m_promptInstall( false ) + , m_disableCancel( false ) + , m_disableCancelDuringExec( false ) +{ + cWarning() << "Using bogus Calamares settings in" + << ( debugMode ? QStringLiteral( "debug" ) : QStringLiteral( "regular" ) ) << "mode"; + s_instance = this; +} + +Settings::Settings( const QString& settingsFilePath, bool debugMode ) + : QObject() + , m_settingsPath( settingsFilePath ) + , m_debug( debugMode ) + , m_doChroot( true ) + , m_promptInstall( false ) + , m_disableCancel( false ) + , m_disableCancelDuringExec( false ) +{ + cDebug() << "Using Calamares settings file at" << settingsFilePath; + QFile file( settingsFilePath ); + if ( file.exists() && file.open( QFile::ReadOnly | QFile::Text ) ) + { + setConfiguration( file.readAll(), file.fileName() ); + } + else + { + cWarning() << "Cannot read settings file" << file.fileName(); + } + + s_instance = this; +} + +bool +Settings::isModuleEnabled( const QString& module ) const +{ + // Iterate over the list of modules searching for a match + for ( const auto& moduleInstance : std::as_const( m_moduleInstances ) ) + { + if ( moduleInstance.key().module() == module ) + { + return true; + } + } + + return false; +} + +void +Settings::reconcileInstancesAndSequence() +{ + // Since moduleFinder captures targetKey by reference, we can + // update targetKey to change what the finder lambda looks for. + Calamares::ModuleSystem::InstanceKey targetKey; + auto moduleFinder = [ &targetKey ]( const InstanceDescription& d ) { return d.isValid() && d.key() == targetKey; }; + + // Check the sequence against the existing instances (which so far are only custom) + for ( const auto& step : m_modulesSequence ) + { + for ( const auto& instanceKey : step.second ) + { + targetKey = instanceKey; + const auto it = std::find_if( m_moduleInstances.constBegin(), m_moduleInstances.constEnd(), moduleFinder ); + if ( it == m_moduleInstances.constEnd() ) + { + if ( instanceKey.isCustom() ) + { + cWarning() << "Custom instance key" << instanceKey << "is not listed in the *instances*"; + } + m_moduleInstances.append( InstanceDescription( instanceKey ) ); + } + } + } +} + +void +Settings::setConfiguration( const QByteArray& ba, const QString& explainName ) +{ + try + { + // Not using Calamares::YAML:: convenience methods because we **want** the exception here + auto config = ::YAML::Load( ba.constData() ); + Q_ASSERT( config.IsMap() ); + + interpretModulesSearch( + debugMode(), Calamares::YAML::toStringList( config[ "modules-search" ] ), m_modulesSearchPaths ); + interpretInstances( config[ "instances" ], m_moduleInstances ); + interpretSequence( config[ "sequence" ], m_modulesSequence ); + + m_brandingComponentName = requireString( config, "branding" ); + m_promptInstall = requireBool( config, "prompt-install", false ); + m_doChroot = !requireBool( config, "dont-chroot", false ); + m_isSetupMode = requireBool( config, "oem-setup", !m_doChroot ); + m_disableCancel = requireBool( config, "disable-cancel", false ); + m_disableCancelDuringExec = requireBool( config, "disable-cancel-during-exec", false ); + m_hideBackAndNextDuringExec = requireBool( config, "hide-back-and-next-during-exec", false ); + m_quitAtEnd = requireBool( config, "quit-at-end", false ); + + reconcileInstancesAndSequence(); + } + catch ( ::YAML::Exception& e ) + { + Calamares::YAML::explainException( e, ba, explainName ); + } +} + +QStringList +Settings::modulesSearchPaths() const +{ + return m_modulesSearchPaths; +} + +Settings::InstanceDescriptionList +Settings::moduleInstances() const +{ + return m_moduleInstances; +} + +Settings::ModuleSequence +Settings::modulesSequence() const +{ + return m_modulesSequence; +} + +QString +Settings::brandingComponentName() const +{ + return m_brandingComponentName; +} + +static QStringList +settingsFileCandidates( bool assumeBuilddir ) +{ + static const char settings[] = "settings.conf"; + + QStringList settingsPaths; + if ( Calamares::isAppDataDirOverridden() ) + { + settingsPaths << Calamares::appDataDir().absoluteFilePath( settings ); + } + else + { + if ( assumeBuilddir ) + { + settingsPaths << QDir::current().absoluteFilePath( settings ); + } + if ( Calamares::haveExtraDirs() ) + { + for ( auto s : Calamares::extraConfigDirs() ) + { + settingsPaths << ( s + settings ); + } + } + settingsPaths << CMAKE_INSTALL_FULL_SYSCONFDIR "/calamares/settings.conf"; // String concat + settingsPaths << Calamares::appDataDir().absoluteFilePath( settings ); + } + + return settingsPaths; +} + +Settings* +Settings::init( bool debugMode ) +{ + if ( s_instance ) + { + cWarning() << "Calamares::Settings already created"; + return s_instance; + } + + QStringList settingsFileCandidatesByPriority = settingsFileCandidates( debugMode ); + + QFileInfo settingsFile; + bool found = false; + + foreach ( const QString& path, settingsFileCandidatesByPriority ) + { + QFileInfo pathFi( path ); + if ( pathFi.exists() && pathFi.isReadable() ) + { + settingsFile = pathFi; + found = true; + break; + } + } + + if ( !found || !settingsFile.exists() || !settingsFile.isReadable() ) + { + cError() << "Cowardly refusing to continue startup without settings." + << Logger::DebugList( settingsFileCandidatesByPriority ); + if ( Calamares::isAppDataDirOverridden() ) + { + cError() << "FATAL: explicitly configured application data directory is missing settings.conf"; + } + else + { + cError() << "FATAL: none of the expected configuration file paths exist."; + } + ::exit( EXIT_FAILURE ); + } + + auto* settings = new Calamares::Settings( settingsFile.absoluteFilePath(), debugMode ); // Creates singleton + if ( settings->modulesSequence().count() < 1 ) + { + cError() << "FATAL: no sequence set."; + ::exit( EXIT_FAILURE ); + } + + return settings; +} + +Settings* +Settings::init( const QString& path ) +{ + if ( s_instance ) + { + cWarning() << "Calamares::Settings already created"; + return s_instance; + } + return new Calamares::Settings( path, true ); +} + +bool +Settings::isValid() const +{ + if ( brandingComponentName().isEmpty() ) + { + cWarning() << "No branding component is set"; + return false; + } + + const auto invalidDescriptor = []( const InstanceDescription& d ) { return !d.isValid(); }; + const auto invalidDescriptorIt + = std::find_if( m_moduleInstances.constBegin(), m_moduleInstances.constEnd(), invalidDescriptor ); + if ( invalidDescriptorIt != m_moduleInstances.constEnd() ) + { + cWarning() << "Invalid module instance in *instances* or *sequence*"; + return false; + } + + return true; +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/Settings.h b/calamares/src/libcalamares/Settings.h new file mode 100644 index 0000000..3ae35c2 --- /dev/null +++ b/calamares/src/libcalamares/Settings.h @@ -0,0 +1,205 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Gabriel Craciunescu + * SPDX-FileCopyrightText: 2019 Dominic Hayes + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef SETTINGS_H +#define SETTINGS_H + +#include "DllMacro.h" +#include "modulesystem/Actions.h" +#include "modulesystem/InstanceKey.h" + +#include +#include + + +namespace Calamares +{ + +/** @brief Description of an instance as named in `settings.conf` + * + * An instance is an intended-step-in-sequence; it is not yet + * a loaded module. The instances have config-files and weights + * which are used by the module manager when loading modules + * and creating jobs. + */ +class DLLEXPORT InstanceDescription +{ + using InstanceKey = Calamares::ModuleSystem::InstanceKey; + +public: + /** @brief An invalid InstanceDescription + * + * Use `fromSettings()` to populate an InstanceDescription and + * check its validity. + */ + InstanceDescription() = default; + + /** @brief An InstanceDescription with no special settings. + * + * Regardless of @p key being custom, sets weight to 1 and + * the configuration file to @c key.module() (plus the ".conf" + * extension). + * + * To InstanceDescription is custom if the key is. + */ + InstanceDescription( const InstanceKey& key ); + + static InstanceDescription fromSettings( const QVariantMap& ); + + bool isValid() const { return m_instanceKey.isValid(); } + + const InstanceKey& key() const { return m_instanceKey; } + QString configFileName() const { return m_configFileName; } + bool isCustom() const { return m_instanceKey.isCustom(); } + int weight() const { return m_weight < 0 ? 1 : m_weight; } + bool explicitWeight() const { return m_weight > 0; } + +private: + InstanceKey m_instanceKey; + QString m_configFileName; + int m_weight = 0; +}; + +class DLLEXPORT Settings : public QObject +{ + Q_OBJECT +#ifdef BUILD_AS_TEST +public: +#endif + explicit Settings( bool debugMode ); + explicit Settings( const QString& settingsFilePath, bool debugMode ); + + void setConfiguration( const QByteArray& configData, const QString& explainName ); + void reconcileInstancesAndSequence(); + +public: + static Settings* instance(); + /// @brief Find a settings.conf, following @p debugMode + static Settings* init( bool debugMode ); + /// @brief Explicif filename, debug is always true (for testing) + static Settings* init( const QString& filename ); + + /// @brief Get the path this settings was created for (may be empty) + QString path() const { return m_settingsPath; } + + QStringList modulesSearchPaths() const; + + using InstanceDescriptionList = QList< InstanceDescription >; + /** @brief All the module instances used + * + * Each module-instance mentioned in `settings.conf` has an entry + * in the moduleInstances list -- both custom entries that are + * in the *instances* section, and each module mentioned in the + * *sequence*. + */ + InstanceDescriptionList moduleInstances() const; + + using ModuleSequence = QList< QPair< ModuleSystem::Action, Calamares::ModuleSystem::InstanceKeyList > >; + /** @brief Representation of *sequence* of execution + * + * Each "section" of the *sequence* key in `settings.conf` gets an + * entry here, stating what kind of action is taken and which modules + * take part (in order). Each entry in the list is an instance key + * which can be found in the moduleInstances() list. + */ + ModuleSequence modulesSequence() const; + + QString brandingComponentName() const; + + /** @brief Are the settings consistent and valid? + * + * Checks that at least branding is set, and that the instances + * and sequence are valid. + */ + bool isValid() const; + + /** @brief Is this a debugging run? + * + * Returns true if Calamares is in debug mode. In debug mode, + * modules and settings are loaded from more locations, to help + * development and debugging. + */ + bool debugMode() const { return m_debug; } + + /** @brief Distinguish "install" from "OEM" modes. + * + * Returns true in "install" mode, which is where actions happen + * in a chroot -- the target system, which exists separately from + * the source system. In "OEM" mode, returns false and most actions + * apply to the *current* (host) system. + */ + bool doChroot() const { return m_doChroot; } + + /** @brief Global setting of prompt-before-install. + * + * Returns true when the configuration is such that the user + * should be prompted one-last-time before any action is taken + * that really affects the machine. + */ + bool showPromptBeforeExecution() const { return m_promptInstall; } + + /** @brief Distinguish between "install" and "setup" modes. + * + * This influences user-visible strings, for instance using the + * word "setup" instead of "install" where relevant. + */ + bool isSetupMode() const { return m_isSetupMode; } + + /** @brief Returns whether the named module is enabled + * + * Returns true if @p module is enabled in settings.conf. Be aware that it + * only tests for a specific module name so if a QML and non-QML version + * of the same module exists, it must be specified explicitly + * + * @p module is a module name or module key e.g. packagechooser) and not a + * full module key+id (e.g. packagechooser@packagechooser) + * + */ + bool isModuleEnabled( const QString& module ) const; + + /** @brief Global setting of disable-cancel: can't cancel ever. */ + bool disableCancel() const { return m_disableCancel; } + + /** @brief Temporary setting of disable-cancel: can't cancel during exec. */ + bool disableCancelDuringExec() const { return m_disableCancelDuringExec; } + + bool hideBackAndNextDuringExec() const { return m_hideBackAndNextDuringExec; } + + /** @brief Is quit-at-end set? (Quit automatically when done) */ + bool quitAtEnd() const { return m_quitAtEnd; } + +private: + static Settings* s_instance; + QString m_settingsPath; + + QStringList m_modulesSearchPaths; + + InstanceDescriptionList m_moduleInstances; + ModuleSequence m_modulesSequence; + + QString m_brandingComponentName; + + // bools are initialized here according to default setting + bool m_debug; + bool m_doChroot = true; + bool m_isSetupMode = false; + bool m_promptInstall = false; + bool m_disableCancel = false; + bool m_disableCancelDuringExec = false; + bool m_hideBackAndNextDuringExec = false; + bool m_quitAtEnd = false; +}; + +} // namespace Calamares + +#endif // SETTINGS_H diff --git a/calamares/src/libcalamares/Tests.cpp b/calamares/src/libcalamares/Tests.cpp new file mode 100644 index 0000000..0b718d1 --- /dev/null +++ b/calamares/src/libcalamares/Tests.cpp @@ -0,0 +1,669 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "compat/Variant.h" +#include "modulesystem/InstanceKey.h" +#include "utils/Logger.h" + +#include +#include +#include + +class TestLibCalamares : public QObject +{ + Q_OBJECT +public: + TestLibCalamares() {} + ~TestLibCalamares() override {} + +private Q_SLOTS: + void testGSModify(); + void testGSLoadSave(); + void testGSLoadSave2(); + void testGSLoadSaveYAMLStringList(); + void testGSNestedLookup(); + + void testInstanceKey(); + void testInstanceDescription(); + + void testSettings(); + + void testJobQueue(); +}; + +void +TestLibCalamares::testGSModify() +{ + Calamares::GlobalStorage gs; + QSignalSpy spy( &gs, &Calamares::GlobalStorage::changed ); + + const QString key( "derp" ); + + QCOMPARE( gs.count(), 0 ); + QVERIFY( !gs.contains( key ) ); + + const int value = 17; + gs.insert( key, value ); + QCOMPARE( gs.count(), 1 ); + QVERIFY( gs.contains( key ) ); + QCOMPARE( Calamares::typeOf( gs.value( key ) ), Calamares::IntVariantType ); + QCOMPARE( gs.value( key ).toString(), QString( "17" ) ); // It isn't a string, but does convert + QCOMPARE( gs.value( key ).toInt(), value ); + + gs.remove( key ); + QCOMPARE( gs.count(), 0 ); + QVERIFY( !gs.contains( key ) ); + + QCOMPARE( spy.count(), 2 ); // one insert, one remove +} + +void +TestLibCalamares::testGSLoadSave() +{ + Calamares::GlobalStorage gs; + const QString jsonfilename( "gs.test.json" ); + const QString yamlfilename( "gs.test.yaml" ); + + gs.insert( "derp", 17 ); + gs.insert( "cow", "moo" ); + + QVariantList l; + for ( const auto& s : QStringList { "dopey", "sneezy" } ) + { + l.append( s ); + } + gs.insert( "dwarfs", l ); + + QCOMPARE( gs.count(), 3 ); + + QVERIFY( gs.saveJson( jsonfilename ) ); + Calamares::GlobalStorage gs2; + QCOMPARE( gs2.count(), 0 ); + QVERIFY( gs2.loadJson( jsonfilename ) ); + QCOMPARE( gs2.count(), 3 ); + QCOMPARE( gs2.data(), gs.data() ); + + QVERIFY( gs.saveYaml( yamlfilename ) ); + Calamares::GlobalStorage gs3; + QCOMPARE( gs3.count(), 0 ); + QVERIFY( gs3.loadYaml( jsonfilename ) ); + QCOMPARE( gs3.count(), 3 ); + QCOMPARE( gs3.data(), gs.data() ); + + // YAML can load as JSON! + QVERIFY( gs3.loadYaml( jsonfilename ) ); + QCOMPARE( gs3.count(), 3 ); + QCOMPARE( gs3.data(), gs.data() ); + + // Failures in loading + QVERIFY( !gs3.loadJson( yamlfilename ) ); + + Calamares::GlobalStorage gs4; + gs4.insert( "derp", 32 ); + gs4.insert( "dorp", "Varsseveld" ); + QCOMPARE( gs4.count(), 2 ); + QVERIFY( gs4.contains( "dorp" ) ); + QCOMPARE( gs4.value( "derp" ).toInt(), 32 ); + QVERIFY( gs4.loadJson( jsonfilename ) ); + // 3 keys from the file, but one overwrite + QCOMPARE( gs4.count(), 4 ); + QVERIFY( gs4.contains( "dorp" ) ); + QCOMPARE( gs4.value( "derp" ).toInt(), 17 ); // This one was overwritten +} + +void +TestLibCalamares::testGSLoadSave2() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + const QString filename( BUILD_AS_TEST "/testdata/yaml-list.conf" ); + QVERIFY2( QFile::exists( filename ), qPrintable( filename ) ); + + Calamares::GlobalStorage gs1; + const QString key( "dwarfs" ); + + QVERIFY( gs1.loadYaml( filename ) ); + QCOMPARE( gs1.count(), 4 ); // From examining the file + QVERIFY( gs1.contains( key ) ); + cDebug() << Calamares::typeOf( gs1.value( key ) ) << gs1.value( key ); + QCOMPARE( Calamares::typeOf( gs1.value( key ) ), Calamares::ListVariantType ); + + const QString yamlfilename( "gs.test.yaml" ); + QVERIFY( gs1.saveYaml( yamlfilename ) ); + + Calamares::GlobalStorage gs2; + QVERIFY( gs2.loadYaml( yamlfilename ) ); + QVERIFY( gs2.contains( key ) ); + QCOMPARE( Calamares::typeOf( gs2.value( key ) ), Calamares::ListVariantType ); +} + +void +TestLibCalamares::testGSLoadSaveYAMLStringList() +{ + Calamares::GlobalStorage gs; + const QString yamlfilename( "gs.test.yaml" ); + + gs.insert( "derp", 17 ); + gs.insert( "cow", "moo" ); + gs.insert( "dwarfs", QStringList { "happy", "dopey", "sleepy", "sneezy", "doc", "thorin", "balin" } ); + + QCOMPARE( gs.count(), 3 ); + QCOMPARE( gs.value( "dwarfs" ).toList().count(), 7 ); // There's seven dwarfs, right? + QVERIFY( gs.value( "dwarfs" ).toStringList().contains( "thorin" ) ); + QVERIFY( !gs.value( "dwarfs" ).toStringList().contains( "gimli" ) ); + + + QVERIFY( gs.saveYaml( yamlfilename ) ); + + Calamares::GlobalStorage gs2; + QCOMPARE( gs2.count(), 0 ); + QVERIFY( gs2.loadYaml( yamlfilename ) ); + QCOMPARE( gs2.count(), 3 ); + QEXPECT_FAIL( "", "QStringList doesn't write out nicely", Continue ); + QCOMPARE( gs2.value( "dwarfs" ).toList().count(), 7 ); // There's seven dwarfs, right? + QCOMPARE( gs2.value( "dwarfs" ).toString(), QStringLiteral( "" ) ); // .. they're gone +} + +void +TestLibCalamares::testGSNestedLookup() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + const QString filename( BUILD_AS_TEST "/testdata/yaml-list.conf" ); + QVERIFY2( QFile::exists( filename ), qPrintable( filename ) ); + + Calamares::GlobalStorage gs2; + QVERIFY( gs2.loadYaml( filename ) ); + + bool ok = false; + const auto v0 = Calamares::lookup( &gs2, "horse.colors.neck", ok ); + QVERIFY( ok ); + QVERIFY( v0.canConvert< QString >() ); + QCOMPARE( v0.toString(), QStringLiteral( "roan" ) ); + const auto v1 = Calamares::lookup( &gs2, "horse.colors.nose", ok ); + QVERIFY( !ok ); + QVERIFY( !v1.isValid() ); + const auto v2 = Calamares::lookup( &gs2, "cow.colors.nose", ok ); + QVERIFY( !ok ); + QVERIFY( !v2.isValid() ); + const auto v3 = Calamares::lookup( &gs2, "dwarfs", ok ); + QVERIFY( ok ); + QVERIFY( v3.canConvert< QVariantList >() ); // because it's a list-valued thing + const auto v4 = Calamares::lookup( &gs2, "dwarfs.sleepy", ok ); + QVERIFY( !ok ); // Sleepy is a value in the list of dwarfs, not a key + const auto v5 = Calamares::lookup( &gs2, "derp", ok ); + QVERIFY( ok ); + QCOMPARE( v5.toInt(), 17 ); +} + +void +TestLibCalamares::testInstanceKey() +{ + using InstanceKey = Calamares::ModuleSystem::InstanceKey; + { + InstanceKey k; + QVERIFY( !k.isValid() ); + QVERIFY( !k.isCustom() ); + QVERIFY( k.module().isEmpty() ); + } + { + InstanceKey k( QStringLiteral( "welcome" ), QString() ); + QVERIFY( k.isValid() ); + QVERIFY( !k.isCustom() ); + QCOMPARE( k.module(), QStringLiteral( "welcome" ) ); + QCOMPARE( k.id(), QStringLiteral( "welcome" ) ); + } + { + InstanceKey k( QStringLiteral( "shellprocess" ), QStringLiteral( "zfssetup" ) ); + QVERIFY( k.isValid() ); + QVERIFY( k.isCustom() ); + QCOMPARE( k.module(), QStringLiteral( "shellprocess" ) ); + QCOMPARE( k.id(), QStringLiteral( "zfssetup" ) ); + } + + { + // This is a bad idea, names and ids with odd punctuation + InstanceKey k( QStringLiteral( " o__O " ), QString() ); + QVERIFY( k.isValid() ); + QVERIFY( !k.isCustom() ); + QCOMPARE( k.module(), QStringLiteral( " o__O " ) ); + } + { + // .. but @ is disallowed + InstanceKey k( QStringLiteral( "welcome@hi" ), QString() ); + QVERIFY( !k.isValid() ); + QVERIFY( !k.isCustom() ); + QVERIFY( k.module().isEmpty() ); + } + + { + InstanceKey k = InstanceKey::fromString( "welcome" ); + QVERIFY( k.isValid() ); + QVERIFY( !k.isCustom() ); + QCOMPARE( k.module(), QStringLiteral( "welcome" ) ); + QCOMPARE( k.id(), QStringLiteral( "welcome" ) ); + } + { + InstanceKey k = InstanceKey::fromString( "welcome@welcome" ); + QVERIFY( k.isValid() ); + QVERIFY( !k.isCustom() ); + QCOMPARE( k.module(), QStringLiteral( "welcome" ) ); + QCOMPARE( k.id(), QStringLiteral( "welcome" ) ); + } + + { + InstanceKey k = InstanceKey::fromString( "welcome@hi" ); + QVERIFY( k.isValid() ); + QVERIFY( k.isCustom() ); + QCOMPARE( k.module(), QStringLiteral( "welcome" ) ); + QCOMPARE( k.id(), QStringLiteral( "hi" ) ); + } + { + InstanceKey k = InstanceKey::fromString( "welcome@hi@hi" ); + QVERIFY( !k.isValid() ); + QVERIFY( !k.isCustom() ); + QVERIFY( k.module().isEmpty() ); + QVERIFY( k.id().isEmpty() ); + } +} + +void +TestLibCalamares::testInstanceDescription() +{ + using InstanceDescription = Calamares::InstanceDescription; + using InstanceKey = Calamares::ModuleSystem::InstanceKey; + + // With invalid keys + // + // + { + InstanceDescription d; + QVERIFY( !d.isValid() ); + QVERIFY( !d.isCustom() ); + QCOMPARE( d.weight(), 0 ); + QVERIFY( d.configFileName().isEmpty() ); + QVERIFY( !d.explicitWeight() ); + } + + { + InstanceDescription d( InstanceKey {} ); // most-vexing, use brace-init instead + QVERIFY( !d.isValid() ); + QVERIFY( !d.isCustom() ); + QCOMPARE( d.weight(), 0 ); + QVERIFY( d.configFileName().isEmpty() ); + QVERIFY( !d.explicitWeight() ); + } + + // Private constructor + // + // This does set up the config file, to default values + { + InstanceDescription d( InstanceKey::fromString( "welcome" ) ); + QVERIFY( d.isValid() ); + QVERIFY( !d.isCustom() ); + QCOMPARE( d.weight(), 1 ); // **now** the constraints kick in + QVERIFY( !d.configFileName().isEmpty() ); + QCOMPARE( d.configFileName(), QStringLiteral( "welcome.conf" ) ); + QVERIFY( !d.explicitWeight() ); + } + + { + InstanceDescription d( InstanceKey::fromString( "welcome@hi" ) ); + QVERIFY( d.isValid() ); + QVERIFY( d.isCustom() ); + QCOMPARE( d.weight(), 1 ); // **now** the constraints kick in + QVERIFY( !d.configFileName().isEmpty() ); + QCOMPARE( d.configFileName(), QStringLiteral( "welcome.conf" ) ); + QVERIFY( !d.explicitWeight() ); + } + + + // From settings, normal program flow + // + // + { + QVariantMap m; + + InstanceDescription d = InstanceDescription::fromSettings( m ); + QVERIFY( !d.isValid() ); + } + { + QVariantMap m; + m.insert( "name", "welcome" ); + + InstanceDescription d = InstanceDescription::fromSettings( m ); + QVERIFY( !d.isValid() ); + QVERIFY( !d.explicitWeight() ); + } + { + QVariantMap m; + m.insert( "module", "welcome" ); + + InstanceDescription d = InstanceDescription::fromSettings( m ); + QVERIFY( d.isValid() ); + QVERIFY( !d.isCustom() ); + // Valid, but no weight set by settings + QCOMPARE( d.weight(), 1 ); + QVERIFY( !d.explicitWeight() ); + + QCOMPARE( d.key().module(), QString( "welcome" ) ); + QCOMPARE( d.key().id(), QString( "welcome" ) ); + QCOMPARE( d.configFileName(), QString( "welcome.conf" ) ); + } + { + QVariantMap m; + m.insert( "module", "welcome" ); + m.insert( "weight", 1 ); + + InstanceDescription d = InstanceDescription::fromSettings( m ); + QVERIFY( d.isValid() ); + QVERIFY( !d.isCustom() ); + + //Valid, set explicitly + QCOMPARE( d.weight(), 1 ); + QVERIFY( d.explicitWeight() ); + + QCOMPARE( d.key().module(), QString( "welcome" ) ); + QCOMPARE( d.key().id(), QString( "welcome" ) ); + QCOMPARE( d.configFileName(), QString( "welcome.conf" ) ); + } + { + QVariantMap m; + m.insert( "module", "welcome" ); + m.insert( "id", "hi" ); + m.insert( "weight", "17" ); // String, that's kind of bogus + + InstanceDescription d = InstanceDescription::fromSettings( m ); + QVERIFY( d.isValid() ); + QVERIFY( d.isCustom() ); + QCOMPARE( d.weight(), 17 ); + QCOMPARE( d.key().module(), QString( "welcome" ) ); + QCOMPARE( d.key().id(), QString( "hi" ) ); + QCOMPARE( d.configFileName(), QString( "welcome.conf" ) ); + QVERIFY( d.explicitWeight() ); + } + { + QVariantMap m; + m.insert( "module", "welcome" ); + m.insert( "id", "hi" ); + m.insert( "weight", 134 ); + m.insert( "config", "hi.conf" ); + + InstanceDescription d = InstanceDescription::fromSettings( m ); + QVERIFY( d.isValid() ); + QVERIFY( d.isCustom() ); + QCOMPARE( d.weight(), 100 ); + QCOMPARE( d.key().module(), QString( "welcome" ) ); + QCOMPARE( d.key().id(), QString( "hi" ) ); + QCOMPARE( d.configFileName(), QString( "hi.conf" ) ); + QVERIFY( d.explicitWeight() ); + } +} + +void +TestLibCalamares::testSettings() +{ + { + Calamares::Settings s( false ); + QVERIFY( !s.debugMode() ); + QVERIFY( !s.isValid() ); + } + { + Calamares::Settings s( true ); + QVERIFY( s.debugMode() ); + QVERIFY( s.moduleInstances().isEmpty() ); + QVERIFY( s.modulesSequence().isEmpty() ); + QVERIFY( s.brandingComponentName().isEmpty() ); + QVERIFY( !s.isValid() ); + + // *INDENT-OFF* + s.setConfiguration( R"(--- +branding: default # needed for it to be considered valid +instances: + - module: welcome + id: hi + weight: 75 + - module: welcome + id: yo + config: yolo.conf +sequence: + - show: + - welcome@hi + - welcome@yo + - dummycpp + - summary + - exec: + - welcome@hi +)", + QStringLiteral( "" ) ); + // *INDENT-ON* + + QVERIFY( s.debugMode() ); + QCOMPARE( s.moduleInstances().count(), 4 ); // there are 4 module instances mentioned + QCOMPARE( s.modulesSequence().count(), 2 ); // 2 steps (show, exec) + QVERIFY( !s.brandingComponentName().isEmpty() ); + QVERIFY( s.isValid() ); + + // Make a lambda where we can adjust what it looks for from the outside, + // by capturing a reference. + QString moduleKey = QString( "welcome" ); + auto moduleFinder = [ &moduleKey ]( const Calamares::InstanceDescription& d ) + { return d.isValid() && d.key().module() == moduleKey; }; + + const auto it0 = std::find_if( s.moduleInstances().constBegin(), s.moduleInstances().constEnd(), moduleFinder ); + QVERIFY( it0 != s.moduleInstances().constEnd() ); + + moduleKey = QString( "derp" ); + const auto it1 = std::find_if( s.moduleInstances().constBegin(), s.moduleInstances().constEnd(), moduleFinder ); + QVERIFY( it1 == s.moduleInstances().constEnd() ); + + int validCount = 0; + int customCount = 0; + for ( const auto& d : s.moduleInstances() ) + { + if ( d.isValid() ) + { + validCount++; + } + if ( d.isCustom() ) + { + customCount++; + } + QVERIFY( d.isCustom() ? d.isValid() : true ); // All custom entries are valid + + if ( !d.isCustom() ) + { + QCOMPARE( d.configFileName(), QString( "%1.conf" ).arg( d.key().module() ) ); + } + else + { + // Specific cases from this config file + if ( d.key().id() == QString( "yo" ) ) + { + QCOMPARE( d.configFileName(), QString( "yolo.conf" ) ); + } + else + { + QCOMPARE( d.configFileName(), QString( "welcome.conf" ) ); // Not set in the settings data + } + } + } + QCOMPARE( customCount, 2 ); + QCOMPARE( validCount, 4 ); // welcome@hi is listed twice, in *show* and *exec* + } +} + +constexpr const std::chrono::milliseconds MAX_TEST_DURATION( 3000 ); +constexpr const int MAX_TEST_SLEEP( 2 ); // seconds, < MAX_TEST_DURATION + +Q_STATIC_ASSERT( std::chrono::seconds( MAX_TEST_SLEEP ) < MAX_TEST_DURATION ); + +class DummyJob : public Calamares::Job +{ +public: + DummyJob( QObject* parent ) + : Calamares::Job( parent ) + { + } + ~DummyJob() override; + + QString prettyName() const override; + Calamares::JobResult exec() override; +}; + +DummyJob::~DummyJob() {} + +QString +DummyJob::prettyName() const +{ + return QString( "DummyJob" ); +} + +Calamares::JobResult +DummyJob::exec() +{ + cDebug() << "Starting DummyJob"; + progress( 0.5 ); + QThread::sleep( MAX_TEST_SLEEP ); + cDebug() << ".. continuing DummyJob"; + progress( 0.75 ); + return Calamares::JobResult::ok(); +} + + +void +TestLibCalamares::testJobQueue() +{ + // Run an empty queue + { + Calamares::JobQueue q; + QVERIFY( !q.isRunning() ); + + QSignalSpy spy_progress( &q, &Calamares::JobQueue::progress ); + QSignalSpy spy_finished( &q, &Calamares::JobQueue::finished ); + QSignalSpy spy_failed( &q, &Calamares::JobQueue::failed ); + + QEventLoop loop; + connect( &q, &Calamares::JobQueue::finished, &loop, &QEventLoop::quit ); + QTimer::singleShot( MAX_TEST_DURATION, &loop, &QEventLoop::quit ); + q.start(); + QVERIFY( q.isRunning() ); + loop.exec(); + QVERIFY( !q.isRunning() ); + QCOMPARE( spy_finished.count(), 1 ); + QCOMPARE( spy_failed.count(), 0 ); + QCOMPARE( spy_progress.count(), 1 ); // just one, 100% at queue end + } + + // Run a dummy queue + { + Calamares::JobQueue q; + QVERIFY( !q.isRunning() ); + + q.enqueue( 8, Calamares::JobList() << Calamares::job_ptr( new DummyJob( this ) ) ); + QSignalSpy spy_progress( &q, &Calamares::JobQueue::progress ); + QSignalSpy spy_finished( &q, &Calamares::JobQueue::finished ); + QSignalSpy spy_failed( &q, &Calamares::JobQueue::failed ); + + QEventLoop loop; + connect( &q, &Calamares::JobQueue::finished, &loop, &QEventLoop::quit ); + QTimer::singleShot( MAX_TEST_DURATION, &loop, &QEventLoop::quit ); + q.start(); + QVERIFY( q.isRunning() ); + loop.exec(); + QVERIFY( !q.isRunning() ); + QCOMPARE( spy_finished.count(), 1 ); + QCOMPARE( spy_failed.count(), 0 ); + // 0% by the queue at job start + // 50% by the job itself + // 90% by the job itself + // 100% by the queue at job end + // 100% by the queue at queue end + QCOMPARE( spy_progress.count(), 5 ); + } + + { + Calamares::JobQueue q; + QVERIFY( !q.isRunning() ); + + q.enqueue( 8, Calamares::JobList() << Calamares::job_ptr( new DummyJob( this ) ) ); + q.enqueue( 12, + Calamares::JobList() << Calamares::job_ptr( new DummyJob( this ) ) + << Calamares::job_ptr( new DummyJob( this ) ) ); + QSignalSpy spy_progress( &q, &Calamares::JobQueue::progress ); + QSignalSpy spy_finished( &q, &Calamares::JobQueue::finished ); + QSignalSpy spy_failed( &q, &Calamares::JobQueue::failed ); + + QEventLoop loop; + connect( &q, &Calamares::JobQueue::finished, &loop, &QEventLoop::quit ); + // Run the loop longer because the jobs take longer (there are 3 of them) + QTimer::singleShot( 3 * MAX_TEST_DURATION, &loop, &QEventLoop::quit ); + q.start(); + QVERIFY( q.isRunning() ); + loop.exec(); + QVERIFY( !q.isRunning() ); + QCOMPARE( spy_finished.count(), 1 ); + QCOMPARE( spy_failed.count(), 0 ); + // 0% by the queue at job start + // 50% by the job itself + // 90% by the job itself + // 100% by the queue at job end + // 4 more for the next job + // 4 more for the next job + // 100% by the queue at queue end + QCOMPARE( spy_progress.count(), 13 ); + + /* Consider how progress will be reported: + * + * - the first module has weight 8, so the 1 job it has has weight 8 + * - the second module has weight 12, so each of its two jobs has weight 6 + * + * Total weight of the modules is 20. So the events are + * + * Job Progress Overall Weight Consumed Overall Progress + * 1 0 0 0.00 + * 1 50 4 0.20 + * 1 75 6 0.30 + * 1 100 8 0.40 + * 2 0 8 0.40 + * 2 50 11 (8 + 50% of 6) 0.55 + * 2 75 12.5 0.625 + * 2 100 14 0.70 + * 3 0 14 0.70 + * 3 50 17 0.85 + * 3 75 18.5 0.925 + * 3 100 20 1.00 + * - 100 20 1.00 + */ + cDebug() << "Progress signals:"; + qreal overallProgress = 0.0; + for ( const auto& e : spy_progress ) + { + QCOMPARE( e.count(), 2 ); + const auto v = e.first(); + QVERIFY( v.canConvert< qreal >() ); + qreal progress = v.toReal(); + cDebug() << Logger::SubEntry << progress; + QVERIFY( progress >= overallProgress ); // Doesn't go backwards + overallProgress = progress; + } + } +} + + +QTEST_GUILESS_MAIN( TestLibCalamares ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/libcalamares/compat/CheckBox.h b/calamares/src/libcalamares/compat/CheckBox.h new file mode 100644 index 0000000..261ed46 --- /dev/null +++ b/calamares/src/libcalamares/compat/CheckBox.h @@ -0,0 +1,30 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_COMPAT_XML_H +#define CALAMARES_COMPAT_XML_H + +#include + +namespace Calamares +{ + +#if QT_VERSION < QT_VERSION_CHECK( 6, 7, 0 ) +using checkBoxStateType = int; +const auto checkBoxStateChangedSignal = &QCheckBox::stateChanged; +constexpr checkBoxStateType checkBoxUncheckedValue = 0; +#else +using checkBoxStateType = Qt::CheckState; +const auto checkBoxStateChangedSignal = &QCheckBox::checkStateChanged; +constexpr checkBoxStateType checkBoxUncheckedValue = Qt::Unchecked; +#endif + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/compat/Mutex.h b/calamares/src/libcalamares/compat/Mutex.h new file mode 100644 index 0000000..36a1473 --- /dev/null +++ b/calamares/src/libcalamares/compat/Mutex.h @@ -0,0 +1,30 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_COMPAT_MUTEX_H +#define CALAMARES_COMPAT_MUTEX_H + +#include + +namespace Calamares +{ + +/* + * In Qt5, QMutexLocker is a class and operates implicitly on + * QMutex but in Qt6 it is a template and needs a specialization. + */ +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) +using MutexLocker = QMutexLocker; +#else +using MutexLocker = QMutexLocker< QMutex >; +#endif + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/compat/Size.h b/calamares/src/libcalamares/compat/Size.h new file mode 100644 index 0000000..5e217e4 --- /dev/null +++ b/calamares/src/libcalamares/compat/Size.h @@ -0,0 +1,23 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_COMPAT_SIZE_H +#define CALAMARES_COMPAT_SIZE_H + +#include + +namespace Calamares +{ +/* Compatibility of size types (e.g. qsizetype, STL sizes, int) between Qt5 and Qt6 */ + +using NumberForTr = int; + +} + +#endif diff --git a/calamares/src/libcalamares/compat/Variant.h b/calamares/src/libcalamares/compat/Variant.h new file mode 100644 index 0000000..dab4e85 --- /dev/null +++ b/calamares/src/libcalamares/compat/Variant.h @@ -0,0 +1,57 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_COMPAT_VARIANT_H +#define CALAMARES_COMPAT_VARIANT_H + +#include + +namespace Calamares +{ +/* Compatibility of QVariant between Qt5 and Qt6 */ +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) +const auto typeOf = []( const QVariant& v ) { return v.type(); }; +const auto ListVariantType = QVariant::List; +const auto MapVariantType = QVariant::Map; +const auto HashVariantType = QVariant::Hash; +const auto StringVariantType = QVariant::String; +const auto CharVariantType = QVariant::Char; +const auto StringListVariantType = QVariant::StringList; +const auto BoolVariantType = QVariant::Bool; +const auto IntVariantType = QVariant::Int; +const auto UIntVariantType = QVariant::UInt; +const auto LongLongVariantType = QVariant::LongLong; +const auto ULongLongVariantType = QVariant::ULongLong; +const auto DoubleVariantType = QVariant::Double; +#else +const auto typeOf = []( const QVariant& v ) { return v.typeId(); }; +const auto ListVariantType = QMetaType::Type::QVariantList; +const auto MapVariantType = QMetaType::Type::QVariantMap; +const auto HashVariantType = QMetaType::Type::QVariantHash; +const auto StringVariantType = QMetaType::Type::QString; +const auto CharVariantType = QMetaType::Type::Char; +const auto StringListVariantType = QMetaType::Type::QStringList; +const auto BoolVariantType = QMetaType::Type::Bool; +const auto IntVariantType = QMetaType::Type::Int; +const auto UIntVariantType = QMetaType::Type::UInt; +const auto LongLongVariantType = QMetaType::Type::LongLong; +const auto ULongLongVariantType = QMetaType::Type::ULongLong; +const auto DoubleVariantType = QMetaType::Type::Double; +#endif + +inline bool +isIntegerVariantType( const QVariant& v ) +{ + const auto t = typeOf( v ); + return t == IntVariantType || t == UIntVariantType || t == LongLongVariantType || t == ULongLongVariantType; +} + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/compat/Xml.h b/calamares/src/libcalamares/compat/Xml.h new file mode 100644 index 0000000..75b775a --- /dev/null +++ b/calamares/src/libcalamares/compat/Xml.h @@ -0,0 +1,42 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_COMPAT_XML_H +#define CALAMARES_COMPAT_XML_H + +#include + +namespace Calamares +{ +#if QT_VERSION < QT_VERSION_CHECK( 6, 6, 0 ) +struct ParseResult +{ + QString errorMessage; + int errorLine = -1; + int errorColumn = -1; +}; + +[[nodiscard]] inline ParseResult +setXmlContent( QDomDocument& doc, const QByteArray& ba ) +{ + ParseResult p; + const bool r = doc.setContent( ba, &p.errorMessage, &p.errorLine, &p.errorColumn ); + return r ? ParseResult {} : p; +} +#else +[[nodiscard]] inline QDomDocument::ParseResult +setXmlContent( QDomDocument& doc, const QByteArray& ba ) +{ + return doc.setContent( ba ); +} +#endif + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/geoip/GeoIPFixed.cpp b/calamares/src/libcalamares/geoip/GeoIPFixed.cpp new file mode 100644 index 0000000..6e8ef81 --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPFixed.cpp @@ -0,0 +1,35 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "GeoIPFixed.h" + +namespace Calamares +{ +namespace GeoIP +{ + +GeoIPFixed::GeoIPFixed( const QString& attribute ) + : Interface( attribute.isEmpty() ? QStringLiteral( "Europe/Amsterdam" ) : attribute ) +{ +} + +QString +GeoIPFixed::rawReply( const QByteArray& ) +{ + return m_element; +} + +GeoIP::RegionZonePair +GeoIPFixed::processReply( const QByteArray& data ) +{ + return splitTZString( rawReply( data ) ); +} + +} // namespace GeoIP +} // namespace Calamares diff --git a/calamares/src/libcalamares/geoip/GeoIPFixed.h b/calamares/src/libcalamares/geoip/GeoIPFixed.h new file mode 100644 index 0000000..b180630 --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPFixed.h @@ -0,0 +1,43 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef GEOIP_GEOIPFIXED_H +#define GEOIP_GEOIPFIXED_H + +#include "Interface.h" + +namespace Calamares +{ +namespace GeoIP +{ +/** @brief GeoIP with a fixed return value + * + * The data is ignored entirely and the attribute value is returned unchanged. + * Note that you still need to provide a usable URL for a successful GeoIP + * lookup -- the URL's data is just ignored. + * + * @note This class is an implementation detail. + */ +class GeoIPFixed : public Interface +{ +public: + /** @brief Configure the value to return from rawReply() + * + * An empty string, which would not be a valid zone name, is + * translated to "Europe/Amsterdam". + */ + explicit GeoIPFixed( const QString& value = QString() ); + + virtual RegionZonePair processReply( const QByteArray& ) override; + virtual QString rawReply( const QByteArray& ) override; +}; + +} // namespace GeoIP +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/geoip/GeoIPJSON.cpp b/calamares/src/libcalamares/geoip/GeoIPJSON.cpp new file mode 100644 index 0000000..6c75bff --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPJSON.cpp @@ -0,0 +1,92 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "GeoIPJSON.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" +#include "utils/Variant.h" +#include "utils/Yaml.h" + +#include + +namespace Calamares +{ +namespace GeoIP +{ + +GeoIPJSON::GeoIPJSON( const QString& attribute ) + : Interface( attribute.isEmpty() ? QStringLiteral( "time_zone" ) : attribute ) +{ +} + +/** @brief Indexes into a map @m by selectors @p l + * + * Each element of @p l is an index into map @m or a sub-map thereof, + * so that "foo.bar.baz" looks up "baz" in the sub-map "bar" of sub-map + * "foo" of @p m, like a regular JSON lookup would. + */ +static QString +selectMap( const QVariantMap& m, const QStringList& l, int index ) +{ + if ( index >= l.count() ) + { + return QString(); + } + + QString attributeName = l[ index ]; + if ( index == l.count() - 1 ) + { + return Calamares::getString( m, attributeName ); + } + else + { + bool success = false; // bogus + if ( m.contains( attributeName ) ) + { + return selectMap( Calamares::getSubMap( m, attributeName, success ), l, index + 1 ); + } + return QString(); + } +} + +QString +GeoIPJSON::rawReply( const QByteArray& data ) +{ + try + { + auto doc = ::YAML::Load( data ); + + QVariant var = Calamares::YAML::toVariant( doc ); + if ( !var.isNull() && var.isValid() && Calamares::typeOf( var ) == Calamares::MapVariantType ) + { + return selectMap( var.toMap(), m_element.split( '.' ), 0 ); + } + else + { + cWarning() << "Invalid YAML data for GeoIPJSON"; + } + } + catch ( ::YAML::Exception& e ) + { + Calamares::YAML::explainException( e, data, "GeoIP data" ); + } + + return QString(); +} + +GeoIP::RegionZonePair +GeoIPJSON::processReply( const QByteArray& data ) +{ + return splitTZString( rawReply( data ) ); +} + +} // namespace GeoIP +} // namespace Calamares diff --git a/calamares/src/libcalamares/geoip/GeoIPJSON.h b/calamares/src/libcalamares/geoip/GeoIPJSON.h new file mode 100644 index 0000000..3c226ff --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPJSON.h @@ -0,0 +1,44 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef GEOIP_GEOIPJSON_H +#define GEOIP_GEOIPJSON_H + +#include "Interface.h" + +namespace Calamares +{ +namespace GeoIP +{ +/** @brief GeoIP lookup for services that return JSON. + * + * This is the original implementation of GeoIP lookup, + * (e.g. using the FreeGeoIP.net service), or similar. + * + * The data is assumed to be in JSON format with a time_zone attribute. + * + * @note This class is an implementation detail. + */ +class GeoIPJSON : public Interface +{ +public: + /** @brief Configure the attribute name which is selected. + * + * If an empty string is passed in (not a valid attribute name), + * then "time_zone" is used. + */ + explicit GeoIPJSON( const QString& attribute = QString() ); + + virtual RegionZonePair processReply( const QByteArray& ) override; + virtual QString rawReply( const QByteArray& ) override; +}; + +} // namespace GeoIP +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/geoip/GeoIPTests.cpp b/calamares/src/libcalamares/geoip/GeoIPTests.cpp new file mode 100644 index 0000000..cc9288e --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPTests.cpp @@ -0,0 +1,262 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "GeoIPTests.h" + +#include "GeoIPFixed.h" +#include "GeoIPJSON.h" +#ifdef QT_XML_LIB +#include "GeoIPXML.h" +#endif +#include "Handler.h" + +#include "network/Manager.h" + +#include + +QTEST_GUILESS_MAIN( GeoIPTests ) + +using namespace Calamares::GeoIP; + +GeoIPTests::GeoIPTests() {} + +GeoIPTests::~GeoIPTests() {} + +void +GeoIPTests::initTestCase() +{ +} + +static const char json_data_attribute[] = "{\"time_zone\":\"Europe/Amsterdam\"}"; + +void +GeoIPTests::testJSON() +{ + GeoIPJSON handler; + auto tz = handler.processReply( json_data_attribute ); + + QCOMPARE( tz.region(), QStringLiteral( "Europe" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "Amsterdam" ) ); + + // JSON is quite tolerant + tz = handler.processReply( "time_zone: \"Europe/Brussels\"" ); + QCOMPARE( tz.zone(), QStringLiteral( "Brussels" ) ); + + tz = handler.processReply( "time_zone: America/New_York\n" ); + QCOMPARE( tz.region(), QStringLiteral( "America" ) ); +} + +void +GeoIPTests::testJSONalt() +{ + GeoIPJSON handler( "zona_de_hora" ); + + auto tz = handler.processReply( json_data_attribute ); + QCOMPARE( tz.region(), QString() ); // Not found + + tz = handler.processReply( "tarifa: 12\nzona_de_hora: Europe/Madrid" ); + QCOMPARE( tz.region(), QStringLiteral( "Europe" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "Madrid" ) ); +} + +void +GeoIPTests::testJSONbad() +{ + static const char data[] = "time_zone: 1"; + + GeoIPJSON handler; + auto tz = handler.processReply( data ); + + tz = handler.processReply( data ); + QCOMPARE( tz.region(), QString() ); + + tz = handler.processReply( "" ); + QCOMPARE( tz.region(), QString() ); + + tz = handler.processReply( "404 Forbidden" ); + QCOMPARE( tz.region(), QString() ); + + tz = handler.processReply( "{ time zone = 'America/LosAngeles'}" ); + QCOMPARE( tz.region(), QString() ); +} + +static const char xml_data_ubiquity[] = + R"( + 85.150.1.1 + OK + NL + NLD + Netherlands + None + None + None + + 50.0 + 4.0 + 0 + Europe/Amsterdam +)"; + +void +GeoIPTests::testXML() +{ +#ifdef QT_XML_LIB + GeoIPXML handler; + auto tz = handler.processReply( xml_data_ubiquity ); + + QCOMPARE( tz.region(), QStringLiteral( "Europe" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "Amsterdam" ) ); +#endif +} + +void +GeoIPTests::testXML2() +{ +#ifdef QT_XML_LIB + static const char data[] + = "America/North Dakota/Beulah"; // With a space! + + GeoIPXML handler; + auto tz = handler.processReply( data ); + + QCOMPARE( tz.region(), QStringLiteral( "America" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "North_Dakota/Beulah" ) ); // Without space +#endif +} + +void +GeoIPTests::testXMLalt() +{ +#ifdef QT_XML_LIB + GeoIPXML handler( "ZT" ); + + auto tz = handler.processReply( "Moon/Dark_side" ); + QCOMPARE( tz.region(), QStringLiteral( "Moon" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "Dark_side" ) ); +#endif +} + +void +GeoIPTests::testXMLbad() +{ +#ifdef QT_XML_LIB + GeoIPXML handler; + auto tz = handler.processReply( "{time_zone: \"Europe/Paris\"}" ); + QCOMPARE( tz.region(), QString() ); + + tz = handler.processReply( "" ); + QCOMPARE( tz.region(), QString() ); + + tz = handler.processReply( "fnord" ); + QCOMPARE( tz.region(), QString() ); +#endif +} + +void +GeoIPTests::testSplitTZ() +{ + using namespace Calamares::GeoIP; + auto tz = splitTZString( QStringLiteral( "Moon/Dark_side" ) ); + QCOMPARE( tz.region(), QStringLiteral( "Moon" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "Dark_side" ) ); + + // Some providers return weirdly escaped data + tz = splitTZString( QStringLiteral( "America\\/NewYork" ) ); + QCOMPARE( tz.region(), QStringLiteral( "America" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "NewYork" ) ); // That's not actually the zone name + + // Check that bogus data fails + tz = splitTZString( QString() ); + QCOMPARE( tz.region(), QString() ); + + tz = splitTZString( QStringLiteral( "America.NewYork" ) ); + QCOMPARE( tz.region(), QString() ); + + // Check that three-level is split properly and space is replaced + tz = splitTZString( QStringLiteral( "America/North Dakota/Beulah" ) ); + QCOMPARE( tz.region(), QStringLiteral( "America" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "North_Dakota/Beulah" ) ); +} + +#define CHECK_GET( t, selector, url ) \ + { \ + auto tz = GeoIP##t( selector ).processReply( Calamares::Network::Manager().synchronousGet( QUrl( url ) ) ); \ + qDebug() << tz; \ + QCOMPARE( default_tz, tz ); \ + auto tz2 = Calamares::GeoIP::Handler( "" #t, url, selector ).get(); \ + qDebug() << tz2; \ + QCOMPARE( default_tz, tz2 ); \ + } + +void +GeoIPTests::testGet() +{ + if ( !QProcessEnvironment::systemEnvironment().contains( QStringLiteral( "TEST_HTTP_GET" ) ) ) + { + qDebug() << "Skipping HTTP GET tests, set TEST_HTTP_GET environment variable to enable"; + return; + } + + GeoIPJSON default_handler; + // Call the KDE service the definitive source. + auto default_tz = default_handler.processReply( + Calamares::Network::Manager().synchronousGet( QUrl( "https://geoip.kde.org/v1/calamares" ) ) ); + + // This is bogus, because the test isn't always run by me + // QCOMPARE( default_tz.region(), QStringLiteral("Europe") ); + // QCOMPARE( default_tz.zone(), QStringLiteral("Amsterdam") ); + QVERIFY( !default_tz.region().isEmpty() ); + QVERIFY( !default_tz.zone().isEmpty() ); + + // Each expansion of CHECK_GET does a synchronous GET, then checks that + // the TZ data is the same as the default_tz; this is fragile if the + // services don't agree on the location of where the test is run. + CHECK_GET( JSON, QString(), "https://geoip.kde.org/v1/calamares" ) // Check it's consistent + CHECK_GET( JSON, QStringLiteral( "timezone" ), "https://ipapi.co/json" ) // Different JSON + CHECK_GET( JSON, QStringLiteral( "timezone" ), "http://ip-api.com/json" ) + + CHECK_GET( JSON, QStringLiteral( "Location.TimeZone" ), "https://geoip.kde.org/debug" ) // 2-level JSON + +#ifdef QT_XML_LIB + CHECK_GET( XML, QString(), "http://geoip.ubuntu.com/lookup" ) // Ubiquity's XML format + CHECK_GET( XML, QString(), "https://geoip.kde.org/v1/ubiquity" ) // Temporary KDE service +#endif +} + +void +GeoIPTests::testFixed() +{ + { + GeoIPFixed f; + auto tz = f.processReply( QByteArray() ); + QCOMPARE( tz.region(), QStringLiteral( "Europe" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "Amsterdam" ) ); + + QCOMPARE( f.processReply( xml_data_ubiquity ), tz ); + QCOMPARE( f.processReply( QByteArray( "derp" ) ), tz ); + } + { + GeoIPFixed f( QStringLiteral( "America/Vancouver" ) ); + auto tz = f.processReply( QByteArray() ); + QCOMPARE( tz.region(), QStringLiteral( "America" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "Vancouver" ) ); + + QCOMPARE( f.processReply( xml_data_ubiquity ), tz ); + QCOMPARE( f.processReply( QByteArray( "derp" ) ), tz ); + } + { + GeoIPFixed f( QStringLiteral( "America/North Dakota/Beulah" ) ); + auto tz = f.processReply( QByteArray() ); + QCOMPARE( tz.region(), QStringLiteral( "America" ) ); + QCOMPARE( tz.zone(), QStringLiteral( "North_Dakota/Beulah" ) ); + + QCOMPARE( f.processReply( xml_data_ubiquity ), tz ); + QCOMPARE( f.processReply( QByteArray( "derp" ) ), tz ); + } +} diff --git a/calamares/src/libcalamares/geoip/GeoIPTests.h b/calamares/src/libcalamares/geoip/GeoIPTests.h new file mode 100644 index 0000000..4d36edb --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPTests.h @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef GEOIPTESTS_H +#define GEOIPTESTS_H + +#include + +class GeoIPTests : public QObject +{ + Q_OBJECT +public: + GeoIPTests(); + ~GeoIPTests() override; + +private Q_SLOTS: + void initTestCase(); + void testFixed(); + void testJSON(); + void testJSONalt(); + void testJSONbad(); + void testXML(); + void testXML2(); + void testXMLalt(); + void testXMLbad(); + void testSplitTZ(); + + void testGet(); +}; + +#endif diff --git a/calamares/src/libcalamares/geoip/GeoIPXML.cpp b/calamares/src/libcalamares/geoip/GeoIPXML.cpp new file mode 100644 index 0000000..f779abc --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPXML.cpp @@ -0,0 +1,92 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "GeoIPXML.h" + +#include "compat/Xml.h" +#include "utils/Logger.h" + +#include + +namespace Calamares +{ +namespace GeoIP +{ + +GeoIPXML::GeoIPXML( const QString& element ) + : Interface( element.isEmpty() ? QStringLiteral( "TimeZone" ) : element ) +{ +} + +static QStringList +getElementTexts( const QByteArray& data, const QString& tag ) +{ + QStringList elements; + + QDomDocument doc; + const auto p = Calamares::setXmlContent( doc, data ); + if ( p.errorMessage.isEmpty() ) + { + const auto tzElements = doc.elementsByTagName( tag ); + cDebug() << "GeoIP found" << tzElements.length() << "elements"; + for ( int it = 0; it < tzElements.length(); ++it ) + { + auto e = tzElements.at( it ).toElement(); + auto e_text = e.text(); + if ( !e_text.isEmpty() ) + { + elements.append( e_text ); + } + } + } + else + { + cWarning() << "GeoIP XML data error:" << p.errorMessage << "(line" << p.errorLine << ':' << p.errorColumn + << ')'; + } + + if ( elements.count() < 1 ) + { + cWarning() << "GeopIP XML had no non-empty elements" << tag; + } + + return elements; +} + +QString +GeoIPXML::rawReply( const QByteArray& data ) +{ + for ( const auto& e : getElementTexts( data, m_element ) ) + { + if ( !e.isEmpty() ) + { + return e; + } + } + + return QString(); +} + +GeoIP::RegionZonePair +GeoIPXML::processReply( const QByteArray& data ) +{ + for ( const auto& e : getElementTexts( data, m_element ) ) + { + auto tz = splitTZString( e ); + if ( tz.isValid() ) + { + return RegionZonePair( tz ); + } + } + + return RegionZonePair(); +} + +} // namespace GeoIP +} // namespace Calamares diff --git a/calamares/src/libcalamares/geoip/GeoIPXML.h b/calamares/src/libcalamares/geoip/GeoIPXML.h new file mode 100644 index 0000000..313b931 --- /dev/null +++ b/calamares/src/libcalamares/geoip/GeoIPXML.h @@ -0,0 +1,44 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef GEOIP_GEOIPXML_H +#define GEOIP_GEOIPXML_H + +#include "Interface.h" + +namespace Calamares +{ +namespace GeoIP +{ +/** @brief GeoIP lookup with XML data + * + * The data is assumed to be in XML format with a + * + * element, which contains the text (string) for the region/zone. This + * format is expected by, e.g. the Ubiquity installer. + * + * @note This class is an implementation detail. + */ +class GeoIPXML : public Interface +{ +public: + /** @brief Configure the element tag which is selected. + * + * If an empty string is passed in (not a valid element tag), + * then "TimeZone" is used. + */ + explicit GeoIPXML( const QString& element = QString() ); + + virtual RegionZonePair processReply( const QByteArray& ) override; + virtual QString rawReply( const QByteArray& ) override; +}; + +} // namespace GeoIP +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/geoip/Handler.cpp b/calamares/src/libcalamares/geoip/Handler.cpp new file mode 100644 index 0000000..b282c20 --- /dev/null +++ b/calamares/src/libcalamares/geoip/Handler.cpp @@ -0,0 +1,176 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Handler.h" + +#include "GeoIPFixed.h" +#include "GeoIPJSON.h" +#if defined( QT_XML_LIB ) +#include "GeoIPXML.h" +#endif + +#include "Settings.h" +#include "network/Manager.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Variant.h" + +#include + +static const NamedEnumTable< Calamares::GeoIP::Handler::Type >& +handlerTypes() +{ + using Type = Calamares::GeoIP::Handler::Type; + + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable names{ + { QStringLiteral( "none" ), Type::None }, + { QStringLiteral( "json" ), Type::JSON }, + { QStringLiteral( "xml" ), Type::XML }, + { QStringLiteral( "fixed" ), Type::Fixed } + }; + // *INDENT-ON* + // clang-format on + + return names; +} + +namespace Calamares +{ +namespace GeoIP +{ + +Handler::Handler() + : m_type( Type::None ) +{ +} + +Handler::Handler( const QString& implementation, const QString& url, const QString& selector ) + : m_type( Type::None ) + , m_url( url ) + , m_selector( selector ) +{ + bool ok = false; + m_type = handlerTypes().find( implementation, ok ); + if ( !ok ) + { + cWarning() << "GeoIP style" << implementation << "is not recognized."; + } + else if ( m_type == Type::None ) + { + cWarning() << "GeoIP style *none* does not do anything."; + } + else if ( m_type == Type::Fixed && Calamares::Settings::instance() + && !Calamares::Settings::instance()->debugMode() ) + { + cWarning() << "GeoIP style *fixed* is not recommended for production."; + } +#if !defined( QT_XML_LIB ) + else if ( m_type == Type::XML ) + { + m_type = Type::None; + cWarning() << "GeoIP style *xml* is not supported in this version of Calamares."; + } +#endif +} + +Handler::~Handler() {} + +static std::unique_ptr< Interface > +create_interface( Handler::Type t, const QString& selector ) +{ + switch ( t ) + { + case Handler::Type::None: + return nullptr; + case Handler::Type::JSON: + return std::make_unique< GeoIPJSON >( selector ); + case Handler::Type::XML: +#if defined( QT_XML_LIB ) + return std::make_unique< GeoIPXML >( selector ); +#else + return nullptr; +#endif + case Handler::Type::Fixed: + return std::make_unique< GeoIPFixed >( selector ); + } + __builtin_unreachable(); +} + +static RegionZonePair +do_query( Handler::Type type, const QString& url, const QString& selector ) +{ + const auto interface = create_interface( type, selector ); + if ( !interface ) + { + return RegionZonePair(); + } + + using namespace Calamares::Network; + return interface->processReply( + Calamares::Network::Manager().synchronousGet( url, { RequestOptions::FakeUserAgent } ) ); +} + +static QString +do_raw_query( Handler::Type type, const QString& url, const QString& selector ) +{ + const auto interface = create_interface( type, selector ); + if ( !interface ) + { + return QString(); + } + + using namespace Calamares::Network; + return interface->rawReply( + Calamares::Network::Manager().synchronousGet( url, { RequestOptions::FakeUserAgent } ) ); +} + +RegionZonePair +Handler::get() const +{ + if ( !isValid() ) + { + return RegionZonePair(); + } + return do_query( m_type, m_url, m_selector ); +} + +QFuture< RegionZonePair > +Handler::query() const +{ + Handler::Type type = m_type; + QString url = m_url; + QString selector = m_selector; + + return QtConcurrent::run( [ = ] { return do_query( type, url, selector ); } ); +} + +QString +Handler::getRaw() const +{ + if ( !isValid() ) + { + return QString(); + } + return do_raw_query( m_type, m_url, m_selector ); +} + +QFuture< QString > +Handler::queryRaw() const +{ + Handler::Type type = m_type; + QString url = m_url; + QString selector = m_selector; + + return QtConcurrent::run( [ = ] { return do_raw_query( type, url, selector ); } ); +} + +} // namespace GeoIP +} // namespace Calamares diff --git a/calamares/src/libcalamares/geoip/Handler.h b/calamares/src/libcalamares/geoip/Handler.h new file mode 100644 index 0000000..4b69612 --- /dev/null +++ b/calamares/src/libcalamares/geoip/Handler.h @@ -0,0 +1,86 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef GEOIP_HANDLER_H +#define GEOIP_HANDLER_H + +#include "Interface.h" + +#include +#include +#include + +namespace Calamares +{ +namespace GeoIP +{ + +/** @brief Handle one complete GeoIP lookup. + * + * This class handles one complete GeoIP lookup. Create it with + * suitable configuration values, then call get(). This is a + * synchronous API and will return an invalid zone pair on + * error or if the configuration is not understood. For an + * async API, use query(). + */ +class DLLEXPORT Handler +{ +public: + enum class Type + { + None, // No lookup, returns empty string + JSON, // JSON-formatted data, returns extracted field + XML, // XML-formatted data, returns extracted field + Fixed // Returns selector string verbatim + }; + + /** @brief An unconfigured handler; this always returns errors. */ + Handler(); + /** @brief A handler for a specific GeoIP source. + * + * The @p implementation name selects an implementation; currently JSON and XML + * are supported. The @p url is retrieved by query() and then the @p selector + * is used to select something from the data returned by the @url. + */ + Handler( const QString& implementation, const QString& url, const QString& selector ); + + ~Handler(); + + /** @brief Synchronously get the GeoIP result. + * + * If the Handler is valid, then do the actual fetching and interpretation + * of data and return the result. An invalid Handler will return an + * invalid (empty) result. + */ + RegionZonePair get() const; + /// @brief Like get, but don't interpret the contents + QString getRaw() const; + + /** @brief Asynchronously get the GeoIP result. + * + * See get() for the return value. + */ + QFuture< RegionZonePair > query() const; + /// @brief Like query, but don't interpret the contents + QFuture< QString > queryRaw() const; + + bool isValid() const { return m_type != Type::None; } + Type type() const { return m_type; } + QString url() const { return m_url; } + QString selector() const { return m_selector; } + +private: + Type m_type; + const QString m_url; + const QString m_selector; +}; + +} // namespace GeoIP +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/geoip/Interface.cpp b/calamares/src/libcalamares/geoip/Interface.cpp new file mode 100644 index 0000000..ce6f567 --- /dev/null +++ b/calamares/src/libcalamares/geoip/Interface.cpp @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Interface.h" + +#include "utils/Logger.h" +#include "utils/String.h" + +namespace Calamares +{ +namespace GeoIP +{ + +Interface::Interface( const QString& e ) + : m_element( e ) +{ +} + +Interface::~Interface() {} + +RegionZonePair +splitTZString( const QString& tz ) +{ + QString timezoneString( tz ); + timezoneString.remove( '\\' ); + timezoneString.replace( ' ', '_' ); + + QStringList tzParts = timezoneString.split( '/', SplitSkipEmptyParts ); + if ( tzParts.size() >= 2 ) + { + QString region = tzParts.takeFirst(); + QString zone = tzParts.join( '/' ); + return RegionZonePair( region, zone ); + } + + return RegionZonePair( QString(), QString() ); +} + +} // namespace GeoIP +} // namespace Calamares diff --git a/calamares/src/libcalamares/geoip/Interface.h b/calamares/src/libcalamares/geoip/Interface.h new file mode 100644 index 0000000..c144cc3 --- /dev/null +++ b/calamares/src/libcalamares/geoip/Interface.h @@ -0,0 +1,127 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef GEOIP_INTERFACE_H +#define GEOIP_INTERFACE_H + +#include "DllMacro.h" + +#include +#include +#include + +#include + +class QByteArray; + +namespace Calamares +{ +namespace GeoIP +{ +/** @brief A Region, Zone pair of strings + * + * A GeoIP lookup returns a timezone, which is represented as a Region, + * Zone pair of strings (e.g. "Europe" and "Amsterdam"). Generally, + * pasting the strings back together with a "/" is the right thing to + * do. The Zone **may** contain a "/" (e.g. "Kentucky/Monticello"). + */ +class DLLEXPORT RegionZonePair +{ +public: + /** @brief Construct from two strings, like qMakePair(). */ + RegionZonePair( const QString& region, const QString& zone ) + : m_region( region ) + , m_zone( zone ) + { + } + + /** @brief Construct from an existing pair. */ + RegionZonePair( const RegionZonePair& p ) + : RegionZonePair( p.m_region, p.m_zone ) + { + } + + /** @brief An invalid zone pair (empty strings). */ + RegionZonePair() = default; + + bool isValid() const { return !m_region.isEmpty(); } + + QString region() const { return m_region; } + QString zone() const { return m_zone; } + + friend bool operator==( const RegionZonePair& lhs, const RegionZonePair& rhs ) noexcept + { + return std::tie( lhs.m_region, lhs.m_zone ) == std::tie( rhs.m_region, rhs.m_zone ); + } + + QString asString() const { return isValid() ? region() + QChar( '/' ) + zone() : QString(); } + +private: + QString m_region; + QString m_zone; +}; + +inline QDebug& +operator<<( QDebug&& s, const RegionZonePair& tz ) +{ + return s << tz.asString(); +} + +inline QDebug& +operator<<( QDebug& s, const RegionZonePair& tz ) +{ + return s << tz.asString(); +} + +/** @brief Splits a region/zone string into a pair. + * + * Cleans up the string by removing backslashes (\\) + * since some providers return silly-escaped names. Replaces + * spaces with _ since some providers return human-readable names. + * Splits on the first / in the resulting string, or returns a + * pair of empty QStrings if it can't. (e.g. America/North Dakota/Beulah + * will return "America", "North_Dakota/Beulah"). + */ +DLLEXPORT RegionZonePair splitTZString( const QString& s ); + +/** + * @brief Interface for GeoIP retrievers. + * + * A GeoIP retriever takes a configured URL (from the config file) + * and can handle the data returned from its interpretation of that + * configured URL, returning a region and zone. + */ +class DLLEXPORT Interface +{ +public: + virtual ~Interface(); + + /** @brief Handle a (successful) request by interpreting the data. + * + * Should return a ( , ) pair, e.g. + * ( "Europe", "Amsterdam" ). This is called **only** if the + * request to the fullUrl was successful; the handler + * is free to read as much, or as little, data as it + * likes. On error, returns a RegionZonePair with empty + * strings (e.g. ( "", "" ) ). + */ + virtual RegionZonePair processReply( const QByteArray& ) = 0; + + /** @brief Get the raw reply data. */ + virtual QString rawReply( const QByteArray& ) = 0; + +protected: + Interface( const QString& element = QString() ); + + QString m_element; // string for selecting from data +}; + +} // namespace GeoIP +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/geoip/test_geoip.cpp b/calamares/src/libcalamares/geoip/test_geoip.cpp new file mode 100644 index 0000000..98f3226 --- /dev/null +++ b/calamares/src/libcalamares/geoip/test_geoip.cpp @@ -0,0 +1,85 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/** + * This is a test-application that does one GeoIP parse. + */ + +#include "GeoIPFixed.h" +#include "GeoIPJSON.h" +#ifdef QT_XML_LIB +#include "GeoIPXML.h" +#endif + +#include "utils/Logger.h" + +#include + +using std::cerr; +using namespace Calamares::GeoIP; + +int +main( int argc, char** argv ) +{ + if ( ( argc != 2 ) && ( argc != 3 ) ) + { + cerr << "Usage: curl url | test_geoip [selector]\n"; + return 1; + } + + QString format( argv[ 1 ] ); + QString selector = argc == 3 ? QString( argv[ 2 ] ) : QString(); + + Logger::setupLogLevel( Logger::LOGVERBOSE ); + cDebug() << "Doing GeoIP interpretation with format=" << format << "selector=" << selector; + + Interface* handler = nullptr; + if ( QStringLiteral( "json" ) == format ) + { + handler = new GeoIPJSON( selector ); + } +#ifdef QT_XML_LIB + else if ( QStringLiteral( "xml" ) == format ) + { + handler = new GeoIPXML( selector ); + } +#endif + else if ( QStringLiteral( "fixed" ) == format ) + { + handler = new GeoIPFixed( selector ); + } + + if ( !handler ) + { + cerr << "Unknown format '" << format.toLatin1().constData() << "'\n"; + return 1; + } + + QByteArray ba; + while ( !std::cin.eof() ) + { + char arr[ 1024 ]; + std::cin.read( arr, sizeof( arr ) ); + int s = static_cast< int >( std::cin.gcount() ); + ba.append( arr, s ); + } + + auto tz = handler->processReply( ba ); + if ( tz.region().isEmpty() ) + { + std::cout << "No TimeZone determined from input.\n"; + } + else + { + std::cout << "TimeZone Region=" << tz.region().toLatin1().constData() + << "\nTimeZone Zone=" << tz.zone().toLatin1().constData() << '\n'; + } + + return 0; +} diff --git a/calamares/src/libcalamares/locale/CountryData_p.cpp b/calamares/src/libcalamares/locale/CountryData_p.cpp new file mode 100644 index 0000000..07ef30e --- /dev/null +++ b/calamares/src/libcalamares/locale/CountryData_p.cpp @@ -0,0 +1,255 @@ +/* GENERATED FILE DO NOT EDIT +* +* === This file is part of Calamares - === +* +* SPDX-FileCopyrightText: 1991-2019 Unicode, Inc. +* SPDX-FileCopyrightText: 2019 Adriaan de Groot +* SPDX-License-Identifier: CC0-1.0 +* +* This file is derived from CLDR data from Unicode, Inc. Applicable terms +* are listed at http://unicode.org/copyright.html , of which the most +* important are: +* +* A. Unicode Copyright +* 1. Copyright © 1991-2019 Unicode, Inc. All rights reserved. +* B. Definitions +* Unicode Data Files ("DATA FILES") include all data files under the directories: +* https://www.unicode.org/Public/ +* C. Terms of Use +* 1. Certain documents and files on this website contain a legend indicating +* that "Modification is permitted." Any person is hereby authorized, +* without fee, to modify such documents and files to create derivative +* works conforming to the Unicode® Standard, subject to Terms and +* Conditions herein. +* 2. Any person is hereby authorized, without fee, to view, use, reproduce, +* and distribute all documents and files, subject to the Terms and +* Conditions herein. +*/ + +/* MODIFICATIONS + * + * Edited anyway: + * 20191211 India (IN) changed to AnyLanguage, since Hindi doesn't make sense. #1284 + * 20210207 Belarus (BY) changed to Russian, as the more-common-language. #1634 + * 20210615 Tokelau and Tuvalu country enum values changed to avoid deprecation warning. + * + */ + +// BEGIN Generated from CLDR data +// *INDENT-OFF* +// clang-format off + +struct CountryData +{ + QLocale::Language l; + QLocale::Country c; + char cc1; + char cc2; +}; + +static constexpr int const country_data_size = 198; + +static const CountryData country_data_table[] = { +{ QLocale::Language::Catalan, QLocale::Country::Andorra, 'A', 'D' }, +{ QLocale::Language::Arabic, QLocale::Country::UnitedArabEmirates, 'A', 'E' }, +{ QLocale::Language::Persian, QLocale::Country::Afghanistan, 'A', 'F' }, +{ QLocale::Language::Albanian, QLocale::Country::Albania, 'A', 'L' }, +{ QLocale::Language::Armenian, QLocale::Country::Armenia, 'A', 'M' }, +{ QLocale::Language::Portuguese, QLocale::Country::Angola, 'A', 'O' }, +{ QLocale::Language::AnyLanguage, QLocale::Country::Antarctica, 'A', 'Q' }, +{ QLocale::Language::Spanish, QLocale::Country::Argentina, 'A', 'R' }, +{ QLocale::Language::Samoan, QLocale::Country::AmericanSamoa, 'A', 'S' }, +{ QLocale::Language::German, QLocale::Country::Austria, 'A', 'T' }, +{ QLocale::Language::Dutch, QLocale::Country::Aruba, 'A', 'W' }, +{ QLocale::Language::Swedish, QLocale::Country::AlandIslands, 'A', 'X' }, +{ QLocale::Language::Azerbaijani, QLocale::Country::Azerbaijan, 'A', 'Z' }, +{ QLocale::Language::Bosnian, QLocale::Country::BosniaAndHerzegowina, 'B', 'A' }, +{ QLocale::Language::Bengali, QLocale::Country::Bangladesh, 'B', 'D' }, +{ QLocale::Language::Dutch, QLocale::Country::Belgium, 'B', 'E' }, +{ QLocale::Language::French, QLocale::Country::BurkinaFaso, 'B', 'F' }, +{ QLocale::Language::Bulgarian, QLocale::Country::Bulgaria, 'B', 'G' }, +{ QLocale::Language::Arabic, QLocale::Country::Bahrain, 'B', 'H' }, +{ QLocale::Language::Rundi, QLocale::Country::Burundi, 'B', 'I' }, +{ QLocale::Language::French, QLocale::Country::Benin, 'B', 'J' }, +{ QLocale::Language::French, QLocale::Country::SaintBarthelemy, 'B', 'L' }, +{ QLocale::Language::Malay, QLocale::Country::Brunei, 'B', 'N' }, +{ QLocale::Language::Spanish, QLocale::Country::Bolivia, 'B', 'O' }, +{ QLocale::Language::Papiamento, QLocale::Country::Bonaire, 'B', 'Q' }, +{ QLocale::Language::Portuguese, QLocale::Country::Brazil, 'B', 'R' }, +{ QLocale::Language::Dzongkha, QLocale::Country::Bhutan, 'B', 'T' }, +{ QLocale::Language::AnyLanguage, QLocale::Country::BouvetIsland, 'B', 'V' }, +{ QLocale::Language::Russian, QLocale::Country::Belarus, 'B', 'Y' }, +{ QLocale::Language::Swahili, QLocale::Country::CongoKinshasa, 'C', 'D' }, +{ QLocale::Language::French, QLocale::Country::CentralAfricanRepublic, 'C', 'F' }, +{ QLocale::Language::French, QLocale::Country::CongoBrazzaville, 'C', 'G' }, +{ QLocale::Language::German, QLocale::Country::Switzerland, 'C', 'H' }, +{ QLocale::Language::French, QLocale::Country::IvoryCoast, 'C', 'I' }, +{ QLocale::Language::Spanish, QLocale::Country::Chile, 'C', 'L' }, +{ QLocale::Language::French, QLocale::Country::Cameroon, 'C', 'M' }, +{ QLocale::Language::Chinese, QLocale::Country::China, 'C', 'N' }, +{ QLocale::Language::Spanish, QLocale::Country::Colombia, 'C', 'O' }, +{ QLocale::Language::AnyLanguage, QLocale::Country::ClippertonIsland, 'C', 'P' }, +{ QLocale::Language::Spanish, QLocale::Country::CostaRica, 'C', 'R' }, +{ QLocale::Language::Spanish, QLocale::Country::Cuba, 'C', 'U' }, +{ QLocale::Language::Portuguese, QLocale::Country::CapeVerde, 'C', 'V' }, +{ QLocale::Language::Papiamento, QLocale::Country::CuraSao, 'C', 'W' }, +{ QLocale::Language::Greek, QLocale::Country::Cyprus, 'C', 'Y' }, +{ QLocale::Language::Czech, QLocale::Country::CzechRepublic, 'C', 'Z' }, +{ QLocale::Language::German, QLocale::Country::Germany, 'D', 'E' }, +{ QLocale::Language::Afar, QLocale::Country::Djibouti, 'D', 'J' }, +{ QLocale::Language::Danish, QLocale::Country::Denmark, 'D', 'K' }, +{ QLocale::Language::Spanish, QLocale::Country::DominicanRepublic, 'D', 'O' }, +{ QLocale::Language::Arabic, QLocale::Country::Algeria, 'D', 'Z' }, +{ QLocale::Language::Spanish, QLocale::Country::CeutaAndMelilla, 'E', 'A' }, +{ QLocale::Language::Spanish, QLocale::Country::Ecuador, 'E', 'C' }, +{ QLocale::Language::Estonian, QLocale::Country::Estonia, 'E', 'E' }, +{ QLocale::Language::Arabic, QLocale::Country::Egypt, 'E', 'G' }, +{ QLocale::Language::Arabic, QLocale::Country::WesternSahara, 'E', 'H' }, +{ QLocale::Language::Tigrinya, QLocale::Country::Eritrea, 'E', 'R' }, +{ QLocale::Language::Spanish, QLocale::Country::Spain, 'E', 'S' }, +{ QLocale::Language::Amharic, QLocale::Country::Ethiopia, 'E', 'T' }, +{ QLocale::Language::English, QLocale::Country::EuropeanUnion, 'E', 'U' }, +{ QLocale::Language::German, QLocale::Country::AnyCountry, 'E', 'Z' }, +{ QLocale::Language::Finnish, QLocale::Country::Finland, 'F', 'I' }, +{ QLocale::Language::Faroese, QLocale::Country::FaroeIslands, 'F', 'O' }, +{ QLocale::Language::French, QLocale::Country::France, 'F', 'R' }, +{ QLocale::Language::French, QLocale::Country::Gabon, 'G', 'A' }, +{ QLocale::Language::Georgian, QLocale::Country::Georgia, 'G', 'E' }, +{ QLocale::Language::French, QLocale::Country::FrenchGuiana, 'G', 'F' }, +{ QLocale::Language::Akan, QLocale::Country::Ghana, 'G', 'H' }, +{ QLocale::Language::Greenlandic, QLocale::Country::Greenland, 'G', 'L' }, +{ QLocale::Language::French, QLocale::Country::Guinea, 'G', 'N' }, +{ QLocale::Language::French, QLocale::Country::Guadeloupe, 'G', 'P' }, +{ QLocale::Language::Spanish, QLocale::Country::EquatorialGuinea, 'G', 'Q' }, +{ QLocale::Language::Greek, QLocale::Country::Greece, 'G', 'R' }, +{ QLocale::Language::AnyLanguage, QLocale::Country::SouthGeorgiaAndTheSouthSandwichIslands, 'G', 'S' }, +{ QLocale::Language::Spanish, QLocale::Country::Guatemala, 'G', 'T' }, +{ QLocale::Language::Portuguese, QLocale::Country::GuineaBissau, 'G', 'W' }, +{ QLocale::Language::Chinese, QLocale::Country::HongKong, 'H', 'K' }, +{ QLocale::Language::AnyLanguage, QLocale::Country::HeardAndMcDonaldIslands, 'H', 'M' }, +{ QLocale::Language::Spanish, QLocale::Country::Honduras, 'H', 'N' }, +{ QLocale::Language::Croatian, QLocale::Country::Croatia, 'H', 'R' }, +{ QLocale::Language::Haitian, QLocale::Country::Haiti, 'H', 'T' }, +{ QLocale::Language::Hungarian, QLocale::Country::Hungary, 'H', 'U' }, +{ QLocale::Language::Spanish, QLocale::Country::CanaryIslands, 'I', 'C' }, +{ QLocale::Language::Indonesian, QLocale::Country::Indonesia, 'I', 'D' }, +{ QLocale::Language::Hebrew, QLocale::Country::Israel, 'I', 'L' }, +{ QLocale::Language::AnyLanguage, QLocale::Country::India, 'I', 'N' }, +{ QLocale::Language::Arabic, QLocale::Country::Iraq, 'I', 'Q' }, +{ QLocale::Language::Persian, QLocale::Country::Iran, 'I', 'R' }, +{ QLocale::Language::Icelandic, QLocale::Country::Iceland, 'I', 'S' }, +{ QLocale::Language::Italian, QLocale::Country::Italy, 'I', 'T' }, +{ QLocale::Language::Arabic, QLocale::Country::Jordan, 'J', 'O' }, +{ QLocale::Language::Japanese, QLocale::Country::Japan, 'J', 'P' }, +{ QLocale::Language::Swahili, QLocale::Country::Kenya, 'K', 'E' }, +{ QLocale::Language::Kirghiz, QLocale::Country::Kyrgyzstan, 'K', 'G' }, +{ QLocale::Language::Khmer, QLocale::Country::Cambodia, 'K', 'H' }, +{ QLocale::Language::Arabic, QLocale::Country::Comoros, 'K', 'M' }, +{ QLocale::Language::Korean, QLocale::Country::NorthKorea, 'K', 'P' }, +{ QLocale::Language::Korean, QLocale::Country::SouthKorea, 'K', 'R' }, +{ QLocale::Language::Arabic, QLocale::Country::Kuwait, 'K', 'W' }, +{ QLocale::Language::Russian, QLocale::Country::Kazakhstan, 'K', 'Z' }, +{ QLocale::Language::Lao, QLocale::Country::Laos, 'L', 'A' }, +{ QLocale::Language::Arabic, QLocale::Country::Lebanon, 'L', 'B' }, +{ QLocale::Language::German, QLocale::Country::Liechtenstein, 'L', 'I' }, +{ QLocale::Language::Sinhala, QLocale::Country::SriLanka, 'L', 'K' }, +{ QLocale::Language::SouthernSotho, QLocale::Country::Lesotho, 'L', 'S' }, +{ QLocale::Language::Lithuanian, QLocale::Country::Lithuania, 'L', 'T' }, +{ QLocale::Language::French, QLocale::Country::Luxembourg, 'L', 'U' }, +{ QLocale::Language::Latvian, QLocale::Country::Latvia, 'L', 'V' }, +{ QLocale::Language::Arabic, QLocale::Country::Libya, 'L', 'Y' }, +{ QLocale::Language::Arabic, QLocale::Country::Morocco, 'M', 'A' }, +{ QLocale::Language::French, QLocale::Country::Monaco, 'M', 'C' }, +{ QLocale::Language::Romanian, QLocale::Country::Moldova, 'M', 'D' }, +{ QLocale::Language::Serbian, QLocale::Country::Montenegro, 'M', 'E' }, +{ QLocale::Language::French, QLocale::Country::SaintMartin, 'M', 'F' }, +{ QLocale::Language::Malagasy, QLocale::Country::Madagascar, 'M', 'G' }, +{ QLocale::Language::Macedonian, QLocale::Country::Macedonia, 'M', 'K' }, +{ QLocale::Language::Bambara, QLocale::Country::Mali, 'M', 'L' }, +{ QLocale::Language::Burmese, QLocale::Country::Myanmar, 'M', 'M' }, +{ QLocale::Language::Mongolian, QLocale::Country::Mongolia, 'M', 'N' }, +{ QLocale::Language::Chinese, QLocale::Country::Macau, 'M', 'O' }, +{ QLocale::Language::French, QLocale::Country::Martinique, 'M', 'Q' }, +{ QLocale::Language::Arabic, QLocale::Country::Mauritania, 'M', 'R' }, +{ QLocale::Language::Maltese, QLocale::Country::Malta, 'M', 'T' }, +{ QLocale::Language::Morisyen, QLocale::Country::Mauritius, 'M', 'U' }, +{ QLocale::Language::Divehi, QLocale::Country::Maldives, 'M', 'V' }, +{ QLocale::Language::Spanish, QLocale::Country::Mexico, 'M', 'X' }, +{ QLocale::Language::Malay, QLocale::Country::Malaysia, 'M', 'Y' }, +{ QLocale::Language::Portuguese, QLocale::Country::Mozambique, 'M', 'Z' }, +{ QLocale::Language::Afrikaans, QLocale::Country::Namibia, 'N', 'A' }, +{ QLocale::Language::French, QLocale::Country::NewCaledonia, 'N', 'C' }, +{ QLocale::Language::Hausa, QLocale::Country::Niger, 'N', 'E' }, +{ QLocale::Language::Spanish, QLocale::Country::Nicaragua, 'N', 'I' }, +{ QLocale::Language::Dutch, QLocale::Country::Netherlands, 'N', 'L' }, +{ QLocale::Language::NorwegianBokmal, QLocale::Country::Norway, 'N', 'O' }, +{ QLocale::Language::Nepali, QLocale::Country::Nepal, 'N', 'P' }, +{ QLocale::Language::Arabic, QLocale::Country::Oman, 'O', 'M' }, +{ QLocale::Language::Spanish, QLocale::Country::Panama, 'P', 'A' }, +{ QLocale::Language::Spanish, QLocale::Country::Peru, 'P', 'E' }, +{ QLocale::Language::French, QLocale::Country::FrenchPolynesia, 'P', 'F' }, +{ QLocale::Language::TokPisin, QLocale::Country::PapuaNewGuinea, 'P', 'G' }, +{ QLocale::Language::Filipino, QLocale::Country::Philippines, 'P', 'H' }, +{ QLocale::Language::Urdu, QLocale::Country::Pakistan, 'P', 'K' }, +{ QLocale::Language::Polish, QLocale::Country::Poland, 'P', 'L' }, +{ QLocale::Language::French, QLocale::Country::SaintPierreAndMiquelon, 'P', 'M' }, +{ QLocale::Language::Spanish, QLocale::Country::PuertoRico, 'P', 'R' }, +{ QLocale::Language::Arabic, QLocale::Country::PalestinianTerritories, 'P', 'S' }, +{ QLocale::Language::Portuguese, QLocale::Country::Portugal, 'P', 'T' }, +{ QLocale::Language::Palauan, QLocale::Country::Palau, 'P', 'W' }, +{ QLocale::Language::Guarani, QLocale::Country::Paraguay, 'P', 'Y' }, +{ QLocale::Language::Arabic, QLocale::Country::Qatar, 'Q', 'A' }, +{ QLocale::Language::English, QLocale::Country::OutlyingOceania, 'Q', 'O' }, +{ QLocale::Language::French, QLocale::Country::Reunion, 'R', 'E' }, +{ QLocale::Language::Romanian, QLocale::Country::Romania, 'R', 'O' }, +{ QLocale::Language::Serbian, QLocale::Country::Serbia, 'R', 'S' }, +{ QLocale::Language::Russian, QLocale::Country::Russia, 'R', 'U' }, +{ QLocale::Language::Kinyarwanda, QLocale::Country::Rwanda, 'R', 'W' }, +{ QLocale::Language::Arabic, QLocale::Country::SaudiArabia, 'S', 'A' }, +{ QLocale::Language::French, QLocale::Country::Seychelles, 'S', 'C' }, +{ QLocale::Language::Arabic, QLocale::Country::Sudan, 'S', 'D' }, +{ QLocale::Language::Swedish, QLocale::Country::Sweden, 'S', 'E' }, +{ QLocale::Language::Slovenian, QLocale::Country::Slovenia, 'S', 'I' }, +{ QLocale::Language::NorwegianBokmal, QLocale::Country::SvalbardAndJanMayenIslands, 'S', 'J' }, +{ QLocale::Language::Slovak, QLocale::Country::Slovakia, 'S', 'K' }, +{ QLocale::Language::Italian, QLocale::Country::SanMarino, 'S', 'M' }, +{ QLocale::Language::French, QLocale::Country::Senegal, 'S', 'N' }, +{ QLocale::Language::Somali, QLocale::Country::Somalia, 'S', 'O' }, +{ QLocale::Language::Dutch, QLocale::Country::Suriname, 'S', 'R' }, +{ QLocale::Language::Portuguese, QLocale::Country::SaoTomeAndPrincipe, 'S', 'T' }, +{ QLocale::Language::Spanish, QLocale::Country::ElSalvador, 'S', 'V' }, +{ QLocale::Language::Arabic, QLocale::Country::Syria, 'S', 'Y' }, +{ QLocale::Language::French, QLocale::Country::Chad, 'T', 'D' }, +{ QLocale::Language::French, QLocale::Country::FrenchSouthernTerritories, 'T', 'F' }, +{ QLocale::Language::French, QLocale::Country::Togo, 'T', 'G' }, +{ QLocale::Language::Thai, QLocale::Country::Thailand, 'T', 'H' }, +{ QLocale::Language::Tajik, QLocale::Country::Tajikistan, 'T', 'J' }, +{ QLocale::Language::TokelauLanguage, QLocale::Country::TokelauCountry, 'T', 'K' }, +{ QLocale::Language::Portuguese, QLocale::Country::EastTimor, 'T', 'L' }, +{ QLocale::Language::Turkmen, QLocale::Country::Turkmenistan, 'T', 'M' }, +{ QLocale::Language::Arabic, QLocale::Country::Tunisia, 'T', 'N' }, +{ QLocale::Language::Tongan, QLocale::Country::Tonga, 'T', 'O' }, +{ QLocale::Language::Turkish, QLocale::Country::Turkey, 'T', 'R' }, +{ QLocale::Language::TuvaluLanguage, QLocale::Country::TuvaluCountry, 'T', 'V' }, +{ QLocale::Language::Chinese, QLocale::Country::Taiwan, 'T', 'W' }, +{ QLocale::Language::Swahili, QLocale::Country::Tanzania, 'T', 'Z' }, +{ QLocale::Language::Ukrainian, QLocale::Country::Ukraine, 'U', 'A' }, +{ QLocale::Language::Swahili, QLocale::Country::Uganda, 'U', 'G' }, +{ QLocale::Language::Spanish, QLocale::Country::Uruguay, 'U', 'Y' }, +{ QLocale::Language::Uzbek, QLocale::Country::Uzbekistan, 'U', 'Z' }, +{ QLocale::Language::Italian, QLocale::Country::VaticanCityState, 'V', 'A' }, +{ QLocale::Language::Spanish, QLocale::Country::Venezuela, 'V', 'E' }, +{ QLocale::Language::Vietnamese, QLocale::Country::Vietnam, 'V', 'N' }, +{ QLocale::Language::Bislama, QLocale::Country::Vanuatu, 'V', 'U' }, +{ QLocale::Language::French, QLocale::Country::WallisAndFutunaIslands, 'W', 'F' }, +{ QLocale::Language::Samoan, QLocale::Country::Samoa, 'W', 'S' }, +{ QLocale::Language::Albanian, QLocale::Country::Kosovo, 'X', 'K' }, +{ QLocale::Language::Arabic, QLocale::Country::Yemen, 'Y', 'E' }, +{ QLocale::Language::French, QLocale::Country::Mayotte, 'Y', 'T' }, +{ QLocale::Language::Shona, QLocale::Country::Zimbabwe, 'Z', 'W' }, +{ QLocale::Language::AnyLanguage, QLocale::Country::AnyCountry, 0, 0 }, +}; + +static_assert( (sizeof(country_data_table) / sizeof(CountryData)) == country_data_size, "Table size mismatch for CountryData" ); + +// END Generated from CLDR data diff --git a/calamares/src/libcalamares/locale/Global.cpp b/calamares/src/libcalamares/locale/Global.cpp new file mode 100644 index 0000000..9a37fd8 --- /dev/null +++ b/calamares/src/libcalamares/locale/Global.cpp @@ -0,0 +1,91 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#include "Global.h" + +#include "GlobalStorage.h" +#include "utils/Logger.h" + +namespace Calamares +{ +namespace Locale +{ + +static const char gsKey[] = "localeConf"; + +template < typename T > +void +insertGS( const QMap< QString, T >& values, QVariantMap& localeConf ) +{ + for ( auto it = values.constBegin(); it != values.constEnd(); ++it ) + { + localeConf.insert( it.key(), it.value() ); + } +} + +void +insertGS( Calamares::GlobalStorage& gs, const QMap< QString, QString >& values, InsertMode mode ) +{ + QVariantMap localeConf = mode == InsertMode::Overwrite ? QVariantMap() : gs.value( gsKey ).toMap(); + insertGS( values, localeConf ); + gs.insert( gsKey, localeConf ); +} + +void +insertGS( Calamares::GlobalStorage& gs, const QVariantMap& values, InsertMode mode ) +{ + QVariantMap localeConf = mode == InsertMode::Overwrite ? QVariantMap() : gs.value( gsKey ).toMap(); + insertGS( values, localeConf ); + gs.insert( gsKey, localeConf ); +} + +void +insertGS( Calamares::GlobalStorage& gs, const QString& key, const QString& value ) +{ + QVariantMap localeConf = gs.value( gsKey ).toMap(); + localeConf.insert( key, value ); + gs.insert( gsKey, localeConf ); +} + +void +removeGS( Calamares::GlobalStorage& gs, const QString& key ) +{ + if ( gs.contains( gsKey ) ) + { + QVariantMap localeConf = gs.value( gsKey ).toMap(); + if ( localeConf.contains( key ) ) + { + localeConf.remove( key ); + gs.insert( gsKey, localeConf ); + } + } +} + +void +clearGS( Calamares::GlobalStorage& gs ) +{ + gs.remove( gsKey ); +} + +QString +readGS( Calamares::GlobalStorage& gs, const QString& key ) +{ + if ( gs.contains( gsKey ) ) + { + QVariantMap localeConf = gs.value( gsKey ).toMap(); + if ( localeConf.contains( key ) ) + { + return localeConf.value( key ).toString(); + } + } + return QString(); +} + +} // namespace Locale +} // namespace Calamares diff --git a/calamares/src/libcalamares/locale/Global.h b/calamares/src/libcalamares/locale/Global.h new file mode 100644 index 0000000..6a4047b --- /dev/null +++ b/calamares/src/libcalamares/locale/Global.h @@ -0,0 +1,83 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/** @file GlobalStorage management for Locale settings + * + * The *localeConf* key in Global Storage is semi-structured, + * and there are multiple modules that write to it (and some that + * read from it). Functions in this file provide access to + * that semi-structured data. + */ + +#ifndef LOCALE_GLOBAL_H +#define LOCALE_GLOBAL_H + +#include "DllMacro.h" + +#include +#include +#include + +namespace Calamares +{ +class GlobalStorage; + +namespace Locale +{ + +/** @brief Selector for methods that insert multiple values. + * + * When inserting, use @c Overwrite to remove all keys not in the collection + * of values being inserted; use @c Merge to preserve whatever is + * already in Global Storage but not mentioned in the collection. + */ +enum class InsertMode +{ + Overwrite, + Merge +}; + +/** @brief Insert the given @p values into the *localeConf* map in @p gs + * + * @param gs The Global Storage to write to + * @param values The collection of keys and values to write to @p gs + * @param mode Indicates whether the *localeConf* key is cleared first + * + * The keys in the collection @p values should be first-level keys + * in *localeConf*, e.g. "LANG" or "LC_TIME". No effort is made to + * enforce this. + */ +DLLEXPORT void insertGS( Calamares::GlobalStorage& gs, const QVariantMap& values, InsertMode mode = InsertMode::Merge ); +/** @brief Insert the given @p values into the *localeConf* map in @p gs + * + * Alternate way of providing the keys and values. + */ +DLLEXPORT void +insertGS( Calamares::GlobalStorage& gs, const QMap< QString, QString >& values, InsertMode mode = InsertMode::Merge ); +/** @brief Write a single @p key and @p value to the *localeConf* map + */ +DLLEXPORT void insertGS( Calamares::GlobalStorage& gs, const QString& key, const QString& value ); +/** @brief Remove a single @p key from the *localeConf* map + */ +DLLEXPORT void removeGS( Calamares::GlobalStorage& gs, const QString& key ); +/** @brief Remove the *localeConf* map from Global Storage + */ +DLLEXPORT void clearGS( Calamares::GlobalStorage& gs ); + +/** @brief Gets a value from the *localeConf* map in @p gs + * + * If the key is not set (or doesn't exist), returns QString(). + */ +DLLEXPORT QString readGS( Calamares::GlobalStorage& gs, const QString& key ); + +} // namespace Locale +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/locale/Lookup.cpp b/calamares/src/libcalamares/locale/Lookup.cpp new file mode 100644 index 0000000..60c36fd --- /dev/null +++ b/calamares/src/libcalamares/locale/Lookup.cpp @@ -0,0 +1,98 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Lookup.h" + +#include "CountryData_p.cpp" + +namespace Calamares +{ +namespace Locale +{ + +struct TwoChar +{ + TwoChar( const QString& code ) + : cc1( 0 ) + , cc2( 0 ) + { + if ( code.length() == 2 ) + { + cc1 = code[ 0 ].toLatin1(); + cc2 = code[ 1 ].toLatin1(); + } + } + + char cc1; + char cc2; +}; + +static const CountryData* +lookup( TwoChar c ) +{ + if ( !c.cc1 ) + { + return nullptr; + } + + const CountryData* p + = std::find_if( country_data_table, + country_data_table + country_data_size, + [ c = c ]( const CountryData& d ) { return ( d.cc1 == c.cc1 ) && ( d.cc2 == c.cc2 ); } ); + if ( p == country_data_table + country_data_size ) + { + return nullptr; + } + return p; +} + +QLocale::Country +countryForCode( const QString& code ) +{ + const CountryData* p = lookup( TwoChar( code ) ); + return p ? p->c : QLocale::Country::AnyCountry; +} + +QLocale::Language +languageForCountry( const QString& code ) +{ + const CountryData* p = lookup( TwoChar( code ) ); + return p ? p->l : QLocale::Language::AnyLanguage; +} + +QPair< QLocale::Country, QLocale::Language > +countryData( const QString& code ) +{ + const CountryData* p = lookup( TwoChar( code ) ); + return p ? qMakePair( p->c, p->l ) : qMakePair( QLocale::Country::AnyCountry, QLocale::Language::AnyLanguage ); +} + +QLocale +countryLocale( const QString& code ) +{ + auto p = countryData( code ); + return QLocale( p.second, p.first ); +} + +QLocale::Language +languageForCountry( QLocale::Country country ) +{ + const CountryData* p = std::find_if( country_data_table, + country_data_table + country_data_size, + [ c = country ]( const CountryData& d ) { return d.c == c; } ); + if ( p == country_data_table + country_data_size ) + { + return QLocale::Language::AnyLanguage; + } + return p->l; +} + +} // namespace Locale +} // namespace Calamares diff --git a/calamares/src/libcalamares/locale/Lookup.h b/calamares/src/libcalamares/locale/Lookup.h new file mode 100644 index 0000000..d026f93 --- /dev/null +++ b/calamares/src/libcalamares/locale/Lookup.h @@ -0,0 +1,47 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef LOCALE_LOOKUP_H +#define LOCALE_LOOKUP_H + +#include "DllMacro.h" + +#include +#include + +namespace Calamares +{ +namespace Locale +{ +/* All the functions in this file do lookups of locale data + * based on CLDR tables; these are lookups that you can't (easily) + * do with just QLocale (e.g. from 2-letter country code to a likely + * locale). + */ + +/// @brief Map a 2-letter code to a Country, or AnyCountry if not found +DLLEXPORT QLocale::Country countryForCode( const QString& code ); +/** @brief Map a Country to a Language, or AnyLanguage if not found + * + * This is a *likely* language for the given country, based on the + * CLDR tables. For instance, this maps Belgium to Dutch. + */ +DLLEXPORT QLocale::Language languageForCountry( QLocale::Country country ); +/// @brief Map a 2-letter code to a Language, or AnyLanguage if not found +DLLEXPORT QLocale::Language languageForCountry( const QString& code ); + +/// @brief Get both Country and Language for a 2-letter code +DLLEXPORT QPair< QLocale::Country, QLocale::Language > countryData( const QString& code ); +/// @brief Get a likely locale for a 2-letter country code +DLLEXPORT QLocale countryLocale( const QString& code ); +} // namespace Locale +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/locale/Tests.cpp b/calamares/src/libcalamares/locale/Tests.cpp new file mode 100644 index 0000000..a2a33f3 --- /dev/null +++ b/calamares/src/libcalamares/locale/Tests.cpp @@ -0,0 +1,553 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "locale/Global.h" +#include "locale/TimeZone.h" +#include "locale/TranslatableConfiguration.h" +#include "locale/TranslationsModel.h" + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" + +#include + +class LocaleTests : public QObject +{ + Q_OBJECT +public: + LocaleTests(); + ~LocaleTests() override; + +private Q_SLOTS: + void initTestCase(); + + void testLanguageModelCount(); + void testTranslatableLanguages(); + void testTranslatableConfig1(); + void testTranslatableConfig2(); + void testTranslatableConfigContext(); + void testLanguageScripts(); + + void testEsperanto(); + void testInterlingue(); + + // TimeZone testing + void testRegions(); + void testSimpleZones(); + void testComplexZones(); + void testTZLookup(); + void testTZIterator(); + void testLocationLookup_data(); + void testLocationLookup(); + void testLocationLookup2(); + + // Global Storage updates + void testGSUpdates(); +}; + +LocaleTests::LocaleTests() {} + +LocaleTests::~LocaleTests() {} + +void +LocaleTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + // Otherwise plain get() is dubious in the TranslatableConfiguration tests + QLocale::setDefault( QLocale( QStringLiteral( "en_US" ) ) ); + QVERIFY( ( QLocale().name() == "C" ) || ( QLocale().name() == "en_US" ) ); +} + +void +LocaleTests::testLanguageModelCount() +{ + const auto* m = Calamares::Locale::availableTranslations(); + + QVERIFY( m ); + QVERIFY( m->rowCount( QModelIndex() ) > 1 ); + + int dutch = m->find( QLocale( "nl_NL" ) ); + QVERIFY( dutch > 0 ); + QCOMPARE( m->find( "NL" ), dutch ); + // must be capitals + QCOMPARE( m->find( "nl" ), -1 ); + QCOMPARE( m->find( QLocale( "nl" ) ), dutch ); + + // Belgium speaks Dutch as well + QCOMPARE( m->find( "BE" ), dutch ); +} + +void +LocaleTests::testLanguageScripts() +{ + const auto* m = Calamares::Locale::availableTranslations(); + + QVERIFY( m ); + + // Cursory test that all the locales found have a sensible language, + // and that some specific languages have sensible corresponding data. + // + // This fails on Esperanto (or, if Esperanto is added to Qt, then + // this will pass and the test after the loop will fail. + for ( int i = 0; i < m->rowCount( QModelIndex() ); ++i ) + { + const auto& label = m->locale( i ); + const auto locale = label.locale(); + cDebug() << label.label() << locale; + + QVERIFY( locale.language() == QLocale::Greek ? locale.script() == QLocale::GreekScript : true ); + QVERIFY( locale.language() == QLocale::Korean ? locale.script() == QLocale::KoreanScript : true ); +#if QT_VERSION < QT_VERSION_CHECK( 6, 6, 0 ) + QVERIFY( locale.language() == QLocale::Lithuanian ? locale.country() == QLocale::Lithuania : true ); +#else + QVERIFY( locale.language() == QLocale::Lithuanian ? locale.territory() == QLocale::Lithuania : true ); +#endif + QVERIFY( locale.language() != QLocale::C ); + } +} + +void +LocaleTests::testEsperanto() +{ + QCOMPARE( QLocale( "eo" ).language(), QLocale::Esperanto ); + QCOMPARE( QLocale( QLocale::Esperanto ).language(), QLocale::Esperanto ); // Probably fails on 5.12, too +} + +void +LocaleTests::testInterlingue() +{ +#if CALAMARES_QT_SUPPORT_INTERLINGUE + // ie was fixed in version ... of Qt + QCOMPARE( QLocale( "ie" ).language(), QLocale::Interlingue ); + QCOMPARE( QLocale( QLocale::Interlingue ).language(), QLocale::Interlingue ); +#else + // ie / Interlingue is borked (is "ie" even the right name?) + QCOMPARE( QLocale( "ie" ).language(), QLocale::C ); + QCOMPARE( QLocale( QLocale::Interlingue ).language(), QLocale::English ); +#endif + + // "ia" exists (post-war variant of Interlingue) + QCOMPARE( QLocale( "ia" ).language(), QLocale::Interlingua ); + // "bork" does not exist + QCOMPARE( QLocale( "bork" ).language(), QLocale::C ); +} + +static const QStringList& +someLanguages() +{ + static QStringList languages { "nl", "de", "da", "nb", "sr@latin", "ar", "ru" }; + return languages; +} + +/** @brief Check consistency of test data + * Check that all the languages used in testing, are actually enabled + * in Calamares translations. + */ +void +LocaleTests::testTranslatableLanguages() +{ + cDebug() << "Translation languages:" << Calamares::Locale::availableLanguages(); + for ( const auto& language : someLanguages() ) + { + // Could be QVERIFY, but then we don't see what language code fails + QCOMPARE( Calamares::Locale::availableLanguages().contains( language ) ? language : QString(), language ); + } +} + +/** @brief Test strings with no translations + */ +void +LocaleTests::testTranslatableConfig1() +{ + Calamares::Locale::TranslatedString ts0; + QVERIFY( ts0.isEmpty() ); + QCOMPARE( ts0.count(), 1 ); // the empty string + + Calamares::Locale::TranslatedString ts1( "Hello" ); + QCOMPARE( ts1.count(), 1 ); + QVERIFY( !ts1.isEmpty() ); + + QCOMPARE( ts1.get(), QStringLiteral( "Hello" ) ); + QCOMPARE( ts1.get( QLocale( "nl" ) ), QStringLiteral( "Hello" ) ); + + QVariantMap map; + map.insert( "description", "description (no language)" ); + Calamares::Locale::TranslatedString ts2( map, "description" ); + QCOMPARE( ts2.count(), 1 ); + QVERIFY( !ts2.isEmpty() ); + + QCOMPARE( ts2.get(), QStringLiteral( "description (no language)" ) ); + QCOMPARE( ts2.get( QLocale( "nl" ) ), QStringLiteral( "description (no language)" ) ); +} + +/** @bref Test strings with translations. + */ +void +LocaleTests::testTranslatableConfig2() +{ + QVariantMap map; + + for ( const auto& language : someLanguages() ) + { + map.insert( QString( "description[%1]" ).arg( language ), + QString( "description (language %1)" ).arg( language ) ); + if ( language != "nl" ) + { + map.insert( QString( "name[%1]" ).arg( language ), QString( "name (language %1)" ).arg( language ) ); + } + } + + // If there's no untranslated string in the map, it is considered empty + Calamares::Locale::TranslatedString ts0( map, "description" ); + QVERIFY( ts0.isEmpty() ); // Because no untranslated string + QCOMPARE( ts0.count(), + someLanguages().count() + 1 ); // But there are entries for the translations, plus an empty string + + // expand the map with untranslated entries + map.insert( QString( "description" ), "description (no language)" ); + map.insert( QString( "name" ), "name (no language)" ); + + Calamares::Locale::TranslatedString ts1( map, "description" ); + // The +1 is because "" is always also inserted + QCOMPARE( ts1.count(), someLanguages().count() + 1 ); + QVERIFY( !ts1.isEmpty() ); + + QCOMPARE( ts1.get(), QStringLiteral( "description (no language)" ) ); // it wasn't set + QCOMPARE( ts1.get( QLocale( "nl" ) ), QStringLiteral( "description (language nl)" ) ); + for ( const auto& language : someLanguages() ) + { + // Skip Serbian (latin) because QLocale() constructed with it + // doesn't retain the @latin part. + if ( language == "sr@latin" ) + { + continue; + } + // Could be QVERIFY, but then we don't see what language code fails + QCOMPARE( ts1.get( QLocale( language ) ) == QString( "description (language %1)" ).arg( language ) ? language + : QString(), + language ); + } + QCOMPARE( ts1.get( QLocale( QLocale::Language::Serbian, QLocale::Script::LatinScript, QLocale::Country::Serbia ) ), + QStringLiteral( "description (language sr@latin)" ) ); + + Calamares::Locale::TranslatedString ts2( map, "name" ); + // We skipped dutch this time + QCOMPARE( ts2.count(), someLanguages().count() ); + QVERIFY( !ts2.isEmpty() ); + + // This key doesn't exist + Calamares::Locale::TranslatedString ts3( map, "front" ); + QVERIFY( ts3.isEmpty() ); + QCOMPARE( ts3.count(), 1 ); // The empty string +} + +void +LocaleTests::testTranslatableConfigContext() +{ + using TS = Calamares::Locale::TranslatedString; + + const QString original( "Quit" ); + TS quitUntranslated( original ); + TS quitTranslated( original, metaObject()->className() ); + + QCOMPARE( quitUntranslated.get(), original ); + QCOMPARE( quitTranslated.get(), original ); + + // Load translation data from QRC + QVERIFY( QFile::exists( ":/lang/localetest_nl.qm" ) ); + QTranslator t; + QVERIFY( t.load( QString( ":/lang/localetest_nl" ) ) ); + QCoreApplication::installTranslator( &t ); + + // Translation doesn't affect the one without context + QCOMPARE( quitUntranslated.get(), original ); + // But the translation **does** affect this class' context + QCOMPARE( quitTranslated.get(), QStringLiteral( "Ophouden" ) ); + QCOMPARE( tr( "Quit" ), QStringLiteral( "Ophouden" ) ); +} + +void +LocaleTests::testRegions() +{ + using namespace Calamares::Locale; + RegionsModel regions; + + QVERIFY( regions.rowCount( QModelIndex() ) > 3 ); // Africa, America, Asia + + QStringList names; + for ( int i = 0; i < regions.rowCount( QModelIndex() ); ++i ) + { + QVariant name = regions.data( regions.index( i ), RegionsModel::NameRole ); + QVERIFY( name.isValid() ); + QVERIFY( !name.toString().isEmpty() ); + names.append( name.toString() ); + } + + QVERIFY( names.contains( "America" ) ); + QVERIFY( !names.contains( "UTC" ) ); +} + +static void +displayedNames( QAbstractItemModel& model, QStringList& names ) +{ + names.clear(); + for ( int i = 0; i < model.rowCount( QModelIndex() ); ++i ) + { + QVariant name = model.data( model.index( i, 0 ), Qt::DisplayRole ); + QVERIFY( name.isValid() ); + QVERIFY( !name.toString().isEmpty() ); + names.append( name.toString() ); + } +} + +void +LocaleTests::testSimpleZones() +{ + using namespace Calamares::Locale; + ZonesModel zones; + + QVERIFY( zones.rowCount( QModelIndex() ) > 24 ); + + QStringList names; + displayedNames( zones, names ); + QVERIFY( names.contains( "Amsterdam" ) ); + if ( !names.contains( "New York" ) ) + { + for ( const auto& s : names ) + { + if ( s.startsWith( 'N' ) ) + { + cDebug() << s; + } + } + } + QVERIFY( names.contains( "New York" ) ); + QVERIFY( !names.contains( "America" ) ); + QVERIFY( !names.contains( "New_York" ) ); +} + +void +LocaleTests::testComplexZones() +{ + using namespace Calamares::Locale; + ZonesModel zones; + RegionalZonesModel europe( &zones ); + + QStringList names; + displayedNames( zones, names ); + QVERIFY( names.contains( "New York" ) ); + QVERIFY( names.contains( "Prague" ) ); + QVERIFY( names.contains( "Abidjan" ) ); + + // No region set + displayedNames( europe, names ); + QVERIFY( names.contains( "New York" ) ); + QVERIFY( names.contains( "Prague" ) ); + QVERIFY( names.contains( "Abidjan" ) ); + + // Now filter + europe.setRegion( "Europe" ); + displayedNames( europe, names ); + QVERIFY( !names.contains( "New York" ) ); + QVERIFY( names.contains( "Prague" ) ); + QVERIFY( !names.contains( "Abidjan" ) ); + + europe.setRegion( "America" ); + displayedNames( europe, names ); + QVERIFY( names.contains( "New York" ) ); + QVERIFY( !names.contains( "Prague" ) ); + QVERIFY( !names.contains( "Abidjan" ) ); + + europe.setRegion( "Africa" ); + displayedNames( europe, names ); + QVERIFY( !names.contains( "New York" ) ); + QVERIFY( !names.contains( "Prague" ) ); + QVERIFY( names.contains( "Abidjan" ) ); +} + +void +LocaleTests::testTZLookup() +{ + using namespace Calamares::Locale; + ZonesModel zones; + + QVERIFY( zones.find( "America", "New_York" ) ); + QCOMPARE( zones.find( "America", "New_York" )->zone(), QStringLiteral( "New_York" ) ); + QCOMPARE( zones.find( "America", "New_York" )->translated(), QStringLiteral( "New York" ) ); + + QVERIFY( !zones.find( "Europe", "New_York" ) ); + QVERIFY( !zones.find( "America", "New York" ) ); +} + +void +LocaleTests::testTZIterator() +{ + using namespace Calamares::Locale; + const ZonesModel zones; + + QVERIFY( zones.find( "Europe", "Rome" ) ); + + int count = 0; + bool seenRome = false; + bool seenGnome = false; + for ( auto it = zones.begin(); it; ++it ) + { + QVERIFY( *it ); + QVERIFY( !( *it )->zone().isEmpty() ); + seenRome |= ( *it )->zone() == QStringLiteral( "Rome" ); + seenGnome |= ( *it )->zone() == QStringLiteral( "Gnome" ); + count++; + } + + QVERIFY( seenRome ); + QVERIFY( !seenGnome ); + QCOMPARE( count, zones.rowCount( QModelIndex() ) ); + + QCOMPARE( zones.data( zones.index( 0 ), ZonesModel::RegionRole ).toString(), QStringLiteral( "Africa" ) ); + QCOMPARE( ( *zones.begin() )->zone(), QStringLiteral( "Abidjan" ) ); +} + +void +LocaleTests::testLocationLookup_data() +{ + QTest::addColumn< double >( "latitude" ); + QTest::addColumn< double >( "longitude" ); + QTest::addColumn< QString >( "name" ); + + QTest::newRow( "London" ) << 50.0 << 0.0 << QString( "London" ); + QTest::newRow( "Tarawa E" ) << 0.0 << 179.0 << QString( "Tarawa" ); + QTest::newRow( "Tarawa W" ) << 0.0 << -179.0 << QString( "Tarawa" ); + + QTest::newRow( "Johannesburg" ) << -26.0 << 28.0 << QString( "Johannesburg" ); // South Africa + QTest::newRow( "Maseru" ) << -29.0 << 27.0 << QString( "Maseru" ); // Lesotho + QTest::newRow( "Windhoek" ) << -22.0 << 17.0 << QString( "Windhoek" ); // Namibia + QTest::newRow( "Port Elisabeth" ) << -33.0 << 25.0 << QString( "Johannesburg" ); // South Africa + QTest::newRow( "Cape Town" ) << -33.0 << 18.0 << QString( "Johannesburg" ); // South Africa +} + +void +LocaleTests::testLocationLookup() +{ + const Calamares::Locale::ZonesModel zones; + + QFETCH( double, latitude ); + QFETCH( double, longitude ); + QFETCH( QString, name ); + + const auto* zone = zones.find( latitude, longitude ); + QVERIFY( zone ); + QCOMPARE( zone->zone(), name ); +} + +void +LocaleTests::testLocationLookup2() +{ + // Official + // ZA -2615+02800 Africa/Johannesburg + // Spot patch + // "ZA -3230+02259 Africa/Johannesburg\n"; + + const Calamares::Locale::ZonesModel zones; + const auto* zone = zones.find( -26.15, 28.00 ); + QCOMPARE( zone->zone(), QString( "Johannesburg" ) ); + // The TZ data sources use minutes-and-seconds notation, + // so "2615" is 26 degrees, 15 minutes, and 15 minutes is + // one-quarter of a degree. + QCOMPARE( zone->latitude(), -26.25 ); + QCOMPARE( zone->longitude(), 28.00 ); + + // Elsewhere in South Africa + const auto* altzone = zones.find( -32.0, 22.0 ); + QCOMPARE( altzone, zone ); // same pointer + QCOMPARE( altzone->zone(), QString( "Johannesburg" ) ); + QCOMPARE( altzone->latitude(), -26.25 ); + QCOMPARE( altzone->longitude(), 28.00 ); + + altzone = zones.find( -29.0, 27.0 ); + QCOMPARE( altzone->zone(), QString( "Maseru" ) ); + // -2928, that's -29 and 28/60 of a degree, is almost half, but we don't want + // to fall foul of variations in double-precision + QCOMPARE( trunc( altzone->latitude() * 1000.0 ), -29466 ); +} + +void +LocaleTests::testGSUpdates() +{ + Calamares::GlobalStorage gs; + + const QString gsKey( "localeConf" ); + + QCOMPARE( gs.value( gsKey ), QVariant() ); + + // Insert one + { + Calamares::Locale::insertGS( gs, "LANG", "en_US" ); + auto map = gs.value( gsKey ).toMap(); + QCOMPARE( map.count(), 1 ); + QCOMPARE( map.value( "LANG" ).toString(), QString( "en_US" ) ); + } + + // Overwrite one + { + Calamares::Locale::insertGS( gs, "LANG", "nl_BE" ); + auto map = gs.value( gsKey ).toMap(); + QCOMPARE( map.count(), 1 ); + QCOMPARE( map.value( "LANG" ).toString(), QString( "nl_BE" ) ); + } + + // Insert a second value + { + Calamares::Locale::insertGS( gs, "LC_TIME", "UTC" ); + auto map = gs.value( gsKey ).toMap(); + QCOMPARE( map.count(), 2 ); + QCOMPARE( map.value( "LANG" ).toString(), QString( "nl_BE" ) ); + QCOMPARE( map.value( "LC_TIME" ).toString(), QString( "UTC" ) ); + } + + // Overwrite parts + { + QMap< QString, QString > kv; + kv.insert( "LANG", "en_SU" ); + kv.insert( "LC_CURRENCY", "rbl" ); + + // Overwrite one, add one + Calamares::Locale::insertGS( gs, kv, Calamares::Locale::InsertMode::Merge ); + auto map = gs.value( gsKey ).toMap(); + QCOMPARE( map.count(), 3 ); + QCOMPARE( map.value( "LANG" ).toString(), QString( "en_SU" ) ); + QCOMPARE( map.value( "LC_TIME" ).toString(), QString( "UTC" ) ); // unchanged + QCOMPARE( map.value( "LC_CURRENCY" ).toString(), QString( "rbl" ) ); + } + + // Overwrite with clear + { + QMap< QString, QString > kv; + kv.insert( "LANG", "en_US" ); + kv.insert( "LC_CURRENCY", "peso" ); + + // Overwrite one, add one + Calamares::Locale::insertGS( gs, kv, Calamares::Locale::InsertMode::Overwrite ); + auto map = gs.value( gsKey ).toMap(); + QCOMPARE( map.count(), 2 ); // the rest were cleared + QCOMPARE( map.value( "LANG" ).toString(), QString( "en_US" ) ); + QVERIFY( !map.contains( "LC_TIME" ) ); + QCOMPARE( map.value( "LC_TIME" ).toString(), QString() ); // removed + QCOMPARE( map.value( "LC_CURRENCY" ).toString(), QString( "peso" ) ); + } +} + +QTEST_GUILESS_MAIN( LocaleTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/libcalamares/locale/TimeZone.cpp b/calamares/src/libcalamares/locale/TimeZone.cpp new file mode 100644 index 0000000..0115618 --- /dev/null +++ b/calamares/src/libcalamares/locale/TimeZone.cpp @@ -0,0 +1,501 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "TimeZone.h" + +#include "locale/TranslatableString.h" +#include "utils/Logger.h" +#include "utils/String.h" + +#include +#include +#include + +static const char TZ_DATA_FILE[] = "/usr/share/zoneinfo/zone.tab"; + +namespace Calamares +{ +namespace Locale +{ +class RegionData; +using RegionVector = QVector< RegionData* >; +using ZoneVector = QVector< TimeZoneData* >; + +/** @brief Turns a string longitude or latitude notation into a double + * + * This handles strings like "+4230+00131" from zone.tab, + * which is degrees-and-minutes notation, and + means north or east. + */ +static double +getRightGeoLocation( QString str ) +{ + double sign = 1, num = 0.00; + + // Determine sign + if ( str.startsWith( '-' ) ) + { + sign = -1; + str.remove( 0, 1 ); + } + else if ( str.startsWith( '+' ) ) + { + str.remove( 0, 1 ); + } + + if ( str.length() == 4 || str.length() == 6 ) + { + num = str.mid( 0, 2 ).toDouble() + str.mid( 2, 2 ).toDouble() / 60.0; + } + else if ( str.length() == 5 || str.length() == 7 ) + { + num = str.mid( 0, 3 ).toDouble() + str.mid( 3, 2 ).toDouble() / 60.0; + } + + return sign * num; +} + +TimeZoneData::TimeZoneData( const QString& region, + const QString& zone, + const QString& country, + double latitude, + double longitude ) + : TranslatableString( zone ) + , m_region( region ) + , m_country( country ) + , m_latitude( latitude ) + , m_longitude( longitude ) +{ + setObjectName( region + '/' + zone ); +} + +QString +TimeZoneData::translated() const +{ + // NOTE: context name must match what's used in zone-extractor.py + return QObject::tr( m_human, "tz_names" ); +} + +class RegionData : public TranslatableString +{ +public: + using TranslatableString::TranslatableString; + QString translated() const override; +}; + +QString +RegionData::translated() const +{ + // NOTE: context name must match what's used in zone-extractor.py + return QObject::tr( m_human, "tz_regions" ); +} + +static void +loadTZData( RegionVector& regions, ZoneVector& zones, QTextStream& in ) +{ + while ( !in.atEnd() ) + { + QString line = in.readLine().trimmed().split( '#', SplitKeepEmptyParts ).first().trimmed(); + if ( line.isEmpty() ) + { + continue; + } + + QStringList list = line.split( QRegularExpression( "[\t ]" ), SplitSkipEmptyParts ); + if ( list.size() < 3 ) + { + continue; + } + + QStringList timezoneParts = list.at( 2 ).split( '/', SplitSkipEmptyParts ); + if ( timezoneParts.size() < 2 ) + { + continue; + } + + QString region = timezoneParts.first().trimmed(); + if ( region.isEmpty() ) + { + continue; + } + + QString countryCode = list.at( 0 ).trimmed(); + if ( countryCode.size() != 2 ) + { + continue; + } + + timezoneParts.removeFirst(); + QString zone = timezoneParts.join( '/' ); + if ( zone.length() < 2 ) + { + continue; + } + + QString position = list.at( 1 ); + int cooSplitPos = position.indexOf( QRegularExpression( "[-+]" ), 1 ); + double latitude; + double longitude; + if ( cooSplitPos > 0 ) + { + latitude = getRightGeoLocation( position.mid( 0, cooSplitPos ) ); + longitude = getRightGeoLocation( position.mid( cooSplitPos ) ); + } + else + { + continue; + } + + // Now we have region, zone, country, lat and longitude + const RegionData* existingRegion = nullptr; + for ( const auto* p : regions ) + { + if ( p->key() == region ) + { + existingRegion = p; + break; + } + } + if ( !existingRegion ) + { + regions.append( new RegionData( region ) ); + } + zones.append( new TimeZoneData( region, zone, countryCode, latitude, longitude ) ); + } +} + +/** @brief Extra, fake, timezones + * + * The timezone locations in zone.tab are not always very useful, + * given Calamares's standard "nearest zone" algorithm: for instance, + * in most locations physically in the country of South Africa, + * Maseru (the capital of Lesotho, and location for timezone Africa/Maseru) + * is closer than Johannesburg (the location for timezone Africa/Johannesburg). + * + * The algorithm picks the wrong place. This is for instance annoying + * when clicking on Cape Town, you get Maseru, and to get Johannesburg + * you need to click somewhere very carefully north of Maserru. + * + * These alternate zones are used to introduce "extra locations" + * into the timezone database, in order to influence the closest-location + * algorithm. Lines are formatted just like in zone.tab: remember the \n + */ +static const char altZones[] = + /* This extra zone is north-east of Karoo National park, + * and means that Western Cape province and a good chunk of + * Northern- and Eastern- Cape provinces get pulled in to Johannesburg. + * Bloemfontein is still closer to Maseru than either correct zone, + * but this is a definite improvement. + */ + "ZA -3230+02259 Africa/Johannesburg\n"; + +class Private : public QObject +{ + Q_OBJECT +public: + RegionVector m_regions; + ZoneVector m_zones; ///< The official timezones and locations + ZoneVector m_altZones; ///< Extra locations for zones + + Private() + { + m_regions.reserve( 12 ); // reasonable guess + m_zones.reserve( 452 ); // wc -l /usr/share/zoneinfo/zone.tab + + // Load the official timezones + { + QFile file( TZ_DATA_FILE ); + if ( file.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + QTextStream in( &file ); + loadTZData( m_regions, m_zones, in ); + } + } + // Load the alternate zones (see documentation at altZones) + { + QTextStream in( altZones ); + loadTZData( m_regions, m_altZones, in ); + } + + std::sort( m_regions.begin(), + m_regions.end(), + []( const RegionData* lhs, const RegionData* rhs ) { return lhs->key() < rhs->key(); } ); + std::sort( m_zones.begin(), + m_zones.end(), + []( const TimeZoneData* lhs, const TimeZoneData* rhs ) + { + if ( lhs->region() == rhs->region() ) + { + return lhs->zone() < rhs->zone(); + } + return lhs->region() < rhs->region(); + } ); + + for ( auto* z : m_zones ) + { + z->setParent( this ); + } + } +}; + +static Private* +privateInstance() +{ + static Private* s_p = new Private; + return s_p; +} + +RegionsModel::RegionsModel( QObject* parent ) + : QAbstractListModel( parent ) + , m_private( privateInstance() ) +{ +} + +RegionsModel::~RegionsModel() {} + +int +RegionsModel::rowCount( const QModelIndex& ) const +{ + return m_private->m_regions.count(); +} + +QVariant +RegionsModel::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() || index.row() < 0 || index.row() >= m_private->m_regions.count() ) + { + return QVariant(); + } + + const auto& region = m_private->m_regions[ index.row() ]; + if ( role == NameRole ) + { + return region->translated(); + } + if ( role == KeyRole ) + { + return region->key(); + } + return QVariant(); +} + +QHash< int, QByteArray > +RegionsModel::roleNames() const +{ + return { { NameRole, "name" }, { KeyRole, "key" } }; +} + +QString +RegionsModel::translated( const QString& region ) const +{ + for ( const auto* p : m_private->m_regions ) + { + if ( p->key() == region ) + { + return p->translated(); + } + } + return region; +} + +ZonesModel::ZonesModel( QObject* parent ) + : QAbstractListModel( parent ) + , m_private( privateInstance() ) +{ +} + +ZonesModel::~ZonesModel() {} + +int +ZonesModel::rowCount( const QModelIndex& ) const +{ + return m_private->m_zones.count(); +} + +QVariant +ZonesModel::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() || index.row() < 0 || index.row() >= m_private->m_zones.count() ) + { + return QVariant(); + } + + const auto* zone = m_private->m_zones[ index.row() ]; + switch ( role ) + { + case NameRole: + return zone->translated(); + case KeyRole: + return zone->key(); + case RegionRole: + return zone->region(); + default: + return QVariant(); + } +} + +QHash< int, QByteArray > +ZonesModel::roleNames() const +{ + return { { NameRole, "name" }, { KeyRole, "key" } }; +} + +const TimeZoneData* +ZonesModel::find( const QString& region, const QString& zone ) const +{ + for ( const auto* p : m_private->m_zones ) + { + if ( p->region() == region && p->zone() == zone ) + { + return p; + } + } + return nullptr; +} + +STATICTEST const TimeZoneData* +find( double startingDistance, + const ZoneVector& zones, + const std::function< double( const TimeZoneData* ) >& distanceFunc ) +{ + double smallestDistance = startingDistance; + const TimeZoneData* closest = nullptr; + + for ( const auto* zone : zones ) + { + double thisDistance = distanceFunc( zone ); + if ( thisDistance < smallestDistance ) + { + closest = zone; + smallestDistance = thisDistance; + } + } + return closest; +} + +const TimeZoneData* +ZonesModel::find( const std::function< double( const TimeZoneData* ) >& distanceFunc ) const +{ + const auto* officialZone = Calamares::Locale::find( 1000000.0, m_private->m_zones, distanceFunc ); + const auto* altZone = Calamares::Locale::find( distanceFunc( officialZone ), m_private->m_altZones, distanceFunc ); + + // If nothing was closer than the official zone already was, altZone is + // nullptr; but if there is a spot-patch, then we need to re-find + // the zone by name, since we want to always return pointers into + // m_zones, not into the alternative spots. + return altZone ? find( altZone->region(), altZone->zone() ) : officialZone; +} + +const TimeZoneData* +ZonesModel::find( double latitude, double longitude ) const +{ + /* This is a somewhat derpy way of finding "closest", + * in that it considers one degree of separation + * either N/S or E/W equal to any other; this obviously + * falls apart at the poles. + */ + auto distance = [ & ]( const TimeZoneData* zone ) -> double + { + // Latitude doesn't wrap around: there is nothing north of 90 + double latitudeDifference = abs( zone->latitude() - latitude ); + + // Longitude **does** wrap around, so consider the case of -178 and 178 + // which differ by 4 degrees. + double westerly = qMin( zone->longitude(), longitude ); + double easterly = qMax( zone->longitude(), longitude ); + double longitudeDifference = 0.0; + if ( westerly < 0.0 && !( easterly < 0.0 ) ) + { + // Only if they're different signs can we have wrap-around. + longitudeDifference = qMin( abs( westerly - easterly ), abs( 360.0 + westerly - easterly ) ); + } + else + { + longitudeDifference = abs( westerly - easterly ); + } + + return latitudeDifference + longitudeDifference; + }; + + return find( distance ); +} + +QObject* +ZonesModel::lookup( double latitude, double longitude ) const +{ + const auto* p = find( latitude, longitude ); + if ( !p ) + { + p = find( "America", "New_York" ); + } + if ( !p ) + { + cWarning() << "No zone (not even New York) found, expect crashes."; + } + return const_cast< QObject* >( reinterpret_cast< const QObject* >( p ) ); +} + +ZonesModel::Iterator::operator bool() const +{ + return 0 <= m_index && m_index < m_p->m_zones.count(); +} + +const TimeZoneData* +ZonesModel::Iterator::operator*() const +{ + if ( *this ) + { + return m_p->m_zones[ m_index ]; + } + return nullptr; +} + +RegionalZonesModel::RegionalZonesModel( Calamares::Locale::ZonesModel* source, QObject* parent ) + : QSortFilterProxyModel( parent ) + , m_private( privateInstance() ) +{ + setSourceModel( source ); +} + +RegionalZonesModel::~RegionalZonesModel() {} + +void +RegionalZonesModel::setRegion( const QString& r ) +{ + if ( r != m_region ) + { + m_region = r; + invalidateFilter(); + emit regionChanged( r ); + } +} + +bool +RegionalZonesModel::filterAcceptsRow( int sourceRow, const QModelIndex& ) const +{ + if ( m_region.isEmpty() ) + { + return true; + } + + if ( sourceRow < 0 || sourceRow >= m_private->m_zones.count() ) + { + return false; + } + + const auto& zone = m_private->m_zones[ sourceRow ]; + return ( zone->m_region == m_region ); +} + +} // namespace Locale +} // namespace Calamares + +#include "utils/moc-warnings.h" + +#include "TimeZone.moc" diff --git a/calamares/src/libcalamares/locale/TimeZone.h b/calamares/src/libcalamares/locale/TimeZone.h new file mode 100644 index 0000000..43db892 --- /dev/null +++ b/calamares/src/libcalamares/locale/TimeZone.h @@ -0,0 +1,237 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/** @file Timezone data and models to go with it + * + * The TimeZoneData class holds information from zone.tab, about + * TZ names and locations (latitude and longitude) for geographic + * lookups. + * + * The RegionModel lists the regions of the world (about 12) and + * ZonesModel lists all the timezones; the RegionalZonesModel provides + * a way to restrict the view of timezones to those of a specific region. + * + */ +#ifndef LOCALE_TIMEZONE_H +#define LOCALE_TIMEZONE_H + +#include "DllMacro.h" + +#include "locale/TranslatableString.h" + +#include +#include +#include +#include + +namespace Calamares +{ +namespace Locale +{ +class Private; +class RegionalZonesModel; +class ZonesModel; + +class DLLEXPORT TimeZoneData : public QObject, TranslatableString +{ + friend class RegionalZonesModel; + friend class ZonesModel; + + Q_OBJECT + + Q_PROPERTY( QString region READ region CONSTANT ) + Q_PROPERTY( QString zone READ zone CONSTANT ) + Q_PROPERTY( QString name READ translated CONSTANT ) + Q_PROPERTY( QString countryCode READ country CONSTANT ) + +public: + TimeZoneData( const QString& region, + const QString& zone, + const QString& country, + double latitude, + double longitude ); + TimeZoneData( const TimeZoneData& ) = delete; + TimeZoneData( TimeZoneData&& ) = delete; + + ///@brief Returns a translated, human-readable form of region/zone (e.g. "America/New York") + QString translated() const override; + + ///@brief Returns the region key (e.g. "Europe") with no translation and no human-readable tweaks + QString region() const { return m_region; } + ///@brief Returns the zone key (e.g. "New_York") with no translation and no human-readable tweaks + QString zone() const { return key(); } + + QString country() const { return m_country; } + double latitude() const { return m_latitude; } + double longitude() const { return m_longitude; } + +private: + QString m_region; + QString m_country; + double m_latitude; + double m_longitude; +}; + +/** @brief The list of timezone regions + * + * The regions are a short list of global areas (Africa, America, India ..) + * which contain zones. + */ +class DLLEXPORT RegionsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles + { + NameRole = Qt::DisplayRole, + KeyRole = Qt::UserRole // So that currentData() will get the key + }; + + RegionsModel( QObject* parent = nullptr ); + ~RegionsModel() override; + + int rowCount( const QModelIndex& parent ) const override; + QVariant data( const QModelIndex& index, int role ) const override; + + QHash< int, QByteArray > roleNames() const override; + +public Q_SLOTS: + /** @brief Provides a human-readable version of the region + * + * Returns @p region unchanged if there is no such region + * or no translation for the region's name. + */ + QString translated( const QString& region ) const; + +private: + Private* m_private; +}; + +class DLLEXPORT ZonesModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles + { + NameRole = Qt::DisplayRole, + KeyRole = Qt::UserRole, // So that currentData() will get the key + RegionRole = Qt::UserRole + 1 + }; + + ZonesModel( QObject* parent = nullptr ); + ~ZonesModel() override; + + int rowCount( const QModelIndex& parent ) const override; + QVariant data( const QModelIndex& index, int role ) const override; + + QHash< int, QByteArray > roleNames() const override; + + /** @brief Iterator for the underlying list of zones + * + * Iterates over all the zones in the model. Operator * may return + * a @c nullptr when the iterator is not valid. Typical usage: + * + * ``` + * for( auto it = model.begin(); it; ++it ) + * { + * const auto* zonedata = *it; + * ... + * } + */ + class Iterator + { + friend class ZonesModel; + Iterator( const Private* m ) + : m_index( 0 ) + , m_p( m ) + { + } + + public: + operator bool() const; + void operator++() { ++m_index; } + const TimeZoneData* operator*() const; + int index() const { return m_index; } + + private: + int m_index; + const Private* m_p; + }; + + Iterator begin() const { return Iterator( m_private ); } + + /** @brief Look up TZ data based on an arbitrary distance function + * + * This is a generic method that can define distance in whatever + * coordinate system is wanted; returns the zone with the smallest + * distance. The @p distanceFunc must return "the distance" for + * each zone. It would be polite to return something non-negative. + * + * Note: not a slot, because the parameter isn't moc-able. + */ + const TimeZoneData* find( const std::function< double( const TimeZoneData* ) >& distanceFunc ) const; + +public Q_SLOTS: + /** @brief Look up TZ data based on its name. + * + * Returns @c nullptr if not found. + */ + const TimeZoneData* find( const QString& region, const QString& zone ) const; + + /** @brief Look up TZ data based on the location. + * + * Returns the nearest zone to the given lat and lon. This is a + * convenience function for calling find(), below, with a standard + * distance function based on the distance between the given + * location (lat and lon) and each zone's given location. + */ + const TimeZoneData* find( double latitude, double longitude ) const; + + /** @brief Look up TZ data based on the location. + * + * Returns the nearest zone, or New York. This is non-const for QML + * purposes, but the object should be considered const anyway. + */ + QObject* lookup( double latitude, double longitude ) const; + +private: + Private* m_private; +}; + +class DLLEXPORT RegionalZonesModel : public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY( QString region READ region WRITE setRegion NOTIFY regionChanged ) + +public: + RegionalZonesModel( ZonesModel* source, QObject* parent = nullptr ); + ~RegionalZonesModel() override; + + bool filterAcceptsRow( int sourceRow, const QModelIndex& sourceParent ) const override; + + QString region() const { return m_region; } + +public Q_SLOTS: + void setRegion( const QString& r ); + +signals: + void regionChanged( const QString& ); + +private: + Private* m_private; + QString m_region; +}; + +} // namespace Locale +} // namespace Calamares + +#endif // LOCALE_TIMEZONE_H diff --git a/calamares/src/libcalamares/locale/TranslatableConfiguration.cpp b/calamares/src/libcalamares/locale/TranslatableConfiguration.cpp new file mode 100644 index 0000000..937202c --- /dev/null +++ b/calamares/src/libcalamares/locale/TranslatableConfiguration.cpp @@ -0,0 +1,118 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "TranslatableConfiguration.h" + +#include "TranslationsModel.h" + +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include +#include +#include + +namespace Calamares +{ +namespace Locale +{ +TranslatedString::TranslatedString( const QString& key, const char* context ) + : m_context( context ) +{ + m_strings[ QString() ] = key; +} + +TranslatedString::TranslatedString( const QString& string ) + : TranslatedString( string, nullptr ) +{ +} + +TranslatedString::TranslatedString( const QVariantMap& map, const QString& key, const char* context ) + : m_context( context ) +{ + // Get the un-decorated value for the key + QString value = Calamares::getString( map, key ); + m_strings[ QString() ] = value; + + for ( auto it = map.constBegin(); it != map.constEnd(); ++it ) + { + QString subkey = it.key(); + if ( subkey == key ) + { + // Already obtained, above + } + else if ( subkey.startsWith( key ) ) + { + QRegularExpressionMatch match; + if ( subkey.indexOf( QRegularExpression( "\\[([a-zA-Z_@]*)\\]" ), 0, &match ) > 0 ) + { + QString language = match.captured( 1 ); + m_strings[ language ] = it.value().toString(); + } + } + } +} + +QString +TranslatedString::get() const +{ + return get( QLocale() ); +} + +QString +TranslatedString::get( const QLocale& locale ) const +{ + // TODO: keep track of special cases like sr@latin and ca@valencia + QString localeName = locale.name(); + // Special case, sr@latin doesn't have the @latin reflected in the name + if ( locale.language() == QLocale::Language::Serbian && locale.script() == QLocale::Script::LatinScript ) + { + localeName = QStringLiteral( "sr@latin" ); + } + + if ( m_strings.contains( localeName ) ) + { + return m_strings[ localeName ]; + } + int index = localeName.indexOf( '@' ); + if ( index > 0 ) + { + localeName.truncate( index ); + if ( m_strings.contains( localeName ) ) + { + return m_strings[ localeName ]; + } + } + + index = localeName.indexOf( '_' ); + if ( index > 0 ) + { + localeName.truncate( index ); + if ( m_strings.contains( localeName ) ) + { + return m_strings[ localeName ]; + } + } + + // If we're given a context to work with, also try the same string in + // the regular translation framework. + const QString& s = m_strings[ QString() ]; + if ( m_context ) + { + return QCoreApplication::translate( m_context, s.toLatin1().constData() ); + } + else + { + return s; + } +} + +} // namespace Locale +} // namespace Calamares diff --git a/calamares/src/libcalamares/locale/TranslatableConfiguration.h b/calamares/src/libcalamares/locale/TranslatableConfiguration.h new file mode 100644 index 0000000..1bc95b2 --- /dev/null +++ b/calamares/src/libcalamares/locale/TranslatableConfiguration.h @@ -0,0 +1,104 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/** @file Run-time translation of strings from configuration files + * + * The TranslatedString class provides a way of doing run-time + * lookups of human-readable strings, from data provided in + * the configuration files (*.conf) for Calamares. This acts + * like "normal" translation through tr() calls, as far as the + * user-visible part goes. + */ +#ifndef LOCALE_TRANSLATABLECONFIGURATION_H +#define LOCALE_TRANSLATABLECONFIGURATION_H + +#include "DllMacro.h" + +#include +#include +#include + +namespace Calamares +{ +namespace Locale +{ +/** @brief A human-readable string from a configuration file + * + * The configuration files can contain human-readable strings, + * but those need their own translations and are not supported + * by QObject::tr or anything else. + */ +class DLLEXPORT TranslatedString +{ +public: + /** @brief Get all the translations connected to @p key + * + * Gets map[key] as the "untranslated" form, and then all the + * keys of the form [lang] are taken as the translation + * for of the untranslated form. + * + * If @p context is not a nullptr, then that is taken as an + * indication to **also** use the regular QObject::tr() translation + * mechanism for these strings. It is recommended to pass in + * metaObject()->className() as context (from a QObject based class) + * to give the TranslatedString the same context as other calls + * to tr() within that class. + * + * The @p context, if any, should point to static data; it is + * **not** owned by the TranslatedString. + */ + TranslatedString( const QVariantMap& map, const QString& key, const char* context = nullptr ); + /** @brief Not-actually-translated string. + */ + TranslatedString( const QString& string ); + /** @brief Proxy for calling QObject::tr() + * + * This is like the two constructors above, with an empty map an a + * non-null context. It will end up calling tr() with that context. + * + * The @p context, if any, should point to static data; it is + * **not** owned by the TranslatedString. + */ + TranslatedString( const QString& key, const char* context ); + /// @brief Empty string + TranslatedString() + : TranslatedString( QString() ) + { + } + + /** @brief How many strings (translations) are there? + * + * This is always at least 1 (for the untranslated string), + * but may be more than 1 even when isEmpty() is true -- + * if there is no untranslated version, for instance. + */ + int count() const { return m_strings.count(); } + /** @brief Consider this string empty? + * + * Only the state of the untranslated string is considered, + * so count() may be more than 1 even while the string is empty. + */ + bool isEmpty() const { return m_strings[ QString() ].isEmpty(); } + + /// @brief Gets the string in the current locale + QString get() const; + + /// @brief Gets the string from the given locale + QString get( const QLocale& ) const; + +private: + // Maps locale name to human-readable string, "" is English + QMap< QString, QString > m_strings; + const char* m_context = nullptr; +}; +} // namespace Locale +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/locale/TranslatableString.cpp b/calamares/src/libcalamares/locale/TranslatableString.cpp new file mode 100644 index 0000000..17a4491 --- /dev/null +++ b/calamares/src/libcalamares/locale/TranslatableString.cpp @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#include "TranslatableString.h" + +/** @brief Massage an identifier into a human-readable form + * + * Makes a copy of @p s, caller must free() it. + */ +static char* +munge( const char* s ) +{ + char* t = strdup( s ); + if ( !t ) + { + return nullptr; + } + + // replace("_"," ") in the Python script + char* p = t; + while ( *p ) + { + if ( ( *p ) == '_' ) + { + *p = ' '; + } + ++p; + } + + return t; +} + +namespace Calamares +{ +namespace Locale +{ + +TranslatableString::TranslatableString( TranslatableString&& t ) + : m_human( nullptr ) + , m_key() +{ + // My pointers are initialized to nullptr + std::swap( m_human, t.m_human ); + std::swap( m_key, t.m_key ); +} + +TranslatableString::TranslatableString( const TranslatableString& t ) + : m_human( t.m_human ? strdup( t.m_human ) : nullptr ) + , m_key( t.m_key ) +{ +} + +TranslatableString::TranslatableString( const char* s1 ) + : m_human( s1 ? munge( s1 ) : nullptr ) + , m_key( s1 ? QString( s1 ) : QString() ) +{ +} + +TranslatableString::TranslatableString( const QString& s ) + : m_human( munge( s.toUtf8().constData() ) ) + , m_key( s ) +{ +} + +TranslatableString::~TranslatableString() +{ + free( m_human ); +} + +} // namespace Locale +} // namespace Calamares diff --git a/calamares/src/libcalamares/locale/TranslatableString.h b/calamares/src/libcalamares/locale/TranslatableString.h new file mode 100644 index 0000000..438a948 --- /dev/null +++ b/calamares/src/libcalamares/locale/TranslatableString.h @@ -0,0 +1,58 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef LOCALE_TRANSLATABLESTRING_H +#define LOCALE_TRANSLATABLESTRING_H + +#include + +namespace Calamares +{ +namespace Locale +{ + +/** @brief A pair of strings, one human-readable, one a key + * + * Given an identifier-like string (e.g. "New_York"), makes + * a human-readable version of that and keeps a copy of the + * identifier itself. + * + * This explicitly uses const char* instead of just being + * QPair because the human-readable part + * may need to be translated through tr(), and that takes a char* + * C-style strings. + */ +class TranslatableString +{ +public: + /// @brief An empty pair + TranslatableString() {} + /// @brief Given an identifier, create the pair + explicit TranslatableString( const char* s1 ); + explicit TranslatableString( const QString& s ); + TranslatableString( TranslatableString&& t ); + TranslatableString( const TranslatableString& ); + virtual ~TranslatableString(); + + /// @brief Give the localized human-readable form + virtual QString translated() const = 0; + QString key() const { return m_key; } + + bool operator==( const TranslatableString& other ) const { return m_key == other.m_key; } + bool operator<( const TranslatableString& other ) const { return m_key < other.m_key; } + +protected: + char* m_human = nullptr; + QString m_key; +}; + +} // namespace Locale +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/locale/Translation.cpp b/calamares/src/libcalamares/locale/Translation.cpp new file mode 100644 index 0000000..d250661 --- /dev/null +++ b/calamares/src/libcalamares/locale/Translation.cpp @@ -0,0 +1,246 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Translation.h" + +#include +#include + +namespace +{ + +struct TranslationSpecialCase +{ + const char* id; // The Calamares ID for the translation + const char** regions; + + QLocale::Language language; + QLocale::Script script; + QLocale::Country country; + + const char* name; // Native name, if different from Qt + + constexpr bool customLocale() const { return language != QLocale::Language::AnyLanguage; } +}; + +/** @brief Handle special cases of Calamares language names + * + * If a given @p id (e.g. en_US, or sr@latin) has special handling, + * put an entry in this table. The QLocale constants are used when a + * particular @p id needs specific configuration, **if** @p language + * is not @c AnyLanguage. The @p name is used as a human-readable + * native name if the Qt name is not suitable. + * + * Another form of lookup maps a @p language + a region-identifier + * to a @p id, running around Qt's neglect of `@region` variants. + * + * Examples: + * - `sr@latin` needs specific Qt Locale settnigs, but the name is OK + * - Chinese needs a specific name, but the Locale is OK + */ +static const char* serbian_latin_regions[] = { "latin", "latn", nullptr }; +static const char* catalan_regions[] = { "valencia", nullptr }; +static constexpr const TranslationSpecialCase special_cases[] = { + { "sr@latin", + serbian_latin_regions, + QLocale::Language::Serbian, + QLocale::Script::LatinScript, + QLocale::Country::Serbia, + nullptr }, + // Valencian is a regional variant of Catalan + { "ca@valencia", + catalan_regions, + QLocale::Language::Catalan, + QLocale::Script::AnyScript, + QLocale::Country::AnyCountry, + "Català (València)" }, + { "ca_ES@valencia", + catalan_regions, + QLocale::Language::Catalan, + QLocale::Script::AnyScript, + QLocale::Country::AnyCountry, + "Català (València)" }, + // Simplified Chinese, but drop the (China) from the name + { "zh_CN", + nullptr, + QLocale::Language::AnyLanguage, + QLocale::Script::AnyScript, + QLocale::Country::AnyCountry, + "简体中文" }, + // Traditional Chinese, but drop (Taiwan) from the name + { "zh_TW", + nullptr, + QLocale::Language::AnyLanguage, + QLocale::Script::AnyScript, + QLocale::Country::AnyCountry, + "繁體中文" }, + { "oc", + nullptr, + QLocale::Language::AnyLanguage, + QLocale::Script::AnyScript, + QLocale::Country::AnyCountry, + "Lenga d'òc" }, + // Luri + { "bqi", + nullptr, + QLocale::Language::NorthernLuri, + QLocale::Script::AnyScript, + QLocale::Country::AnyCountry, + nullptr }, + // Interlingue is mapped to interlingu*a* (up until Qt version ...) because + // the real Language::Interlingue acts like C locale. + { "ie", + nullptr, + CALAMARES_QT_SUPPORT_INTERLINGUE ? QLocale::Language::Interlingue : QLocale::Language::Interlingua, + QLocale::Script::AnyScript, + QLocale::Country::AnyCountry, + "Interlingue" }, +}; + +static inline bool +lookup_region( const QByteArray& region, const char** regions_list ) +{ + if ( regions_list ) + { + while ( *regions_list ) + { + if ( region == *regions_list ) + { + return true; + } + regions_list++; + } + } + return false; +} + +static QString +specialCaseSystemLanguage() +{ + const QByteArray lang_p = qgetenv( "LANG" ); + if ( lang_p.isEmpty() ) + { + // This will figure out the system language some other way + return {}; + } + + auto lang_parts = lang_p.split( '@' ); + if ( lang_parts.size() != 2 ) + { + return {}; + } + + QLocale locale( QString::fromLatin1( lang_p ) ); + auto it + = std::find_if( std::cbegin( special_cases ), + std::cend( special_cases ), + [ language = locale.language(), region = lang_parts[ 1 ] ]( const TranslationSpecialCase& s ) + { return ( s.language == language ) && lookup_region( region, s.regions ); } ); + return ( it != std::cend( special_cases ) ) ? QString::fromLatin1( it->id ) : QString(); +} + +///@brief Country (territory) name for this locale +QString +territoryName( const QLocale& locale ) +{ +#if QT_VERSION < QT_VERSION_CHECK( 6, 6, 0 ) + return QLocale::countryToString( locale.country() ); +#else + return QLocale::territoryToString( locale.territory() ); +#endif +} + +QString +nativeTerritoryName( const QLocale& locale ) +{ +#if QT_VERSION < QT_VERSION_CHECK( 6, 6, 0 ) + return locale.nativeCountryName(); +#else + return locale.nativeTerritoryName(); +#endif +} + +bool +needsTerritorialDisambiguation( const QLocale& locale ) +{ +#if QT_VERSION < QT_VERSION_CHECK( 6, 6, 0 ) + return QLocale::countriesForLanguage( locale.language() ).count() > 1; +#else + std::set s; + for(const auto & l : QLocale::matchingLocales( locale.language(), QLocale::Script::AnyScript, QLocale::Territory::AnyTerritory )) + { + s.insert(l.territory()); + } + return s.size() > 1; +#endif +} + + +} // namespace + +namespace Calamares +{ +namespace Locale +{ + +Translation::Translation( QObject* parent ) + : Translation( { specialCaseSystemLanguage() }, LabelFormat::IfNeededWithCountry, parent ) +{ +} + +Translation::Translation( const Id& localeId, LabelFormat format, QObject* parent ) + : QObject( parent ) + , m_locale( getLocale( localeId ) ) + , m_localeId( localeId.name.isEmpty() ? m_locale.name() : localeId.name ) +{ + auto it = std::find_if( std::cbegin( special_cases ), + std::cend( special_cases ), + [ &localeId ]( const TranslationSpecialCase& s ) { return localeId.name == s.id; } ); + const char* name = ( it != std::cend( special_cases ) ) ? it->name : nullptr; + + QString longFormat = QObject::tr( "%1 (%2)" ); + + QString languageName = name ? QString::fromUtf8( name ) : m_locale.nativeLanguageName(); + QString englishName = m_locale.languageToString( m_locale.language() ); + + if ( languageName.isEmpty() ) + { + languageName = QString( "* %1 (%2)" ).arg( localeId.name, englishName ); + } + + bool needsCountryName = ( format == LabelFormat::AlwaysWithCountry ) + || ( !name && localeId.name.contains( '_' ) && needsTerritorialDisambiguation( ( m_locale ) ) ); + const QString countryName = needsCountryName ? nativeTerritoryName( m_locale ) : QString(); + m_label = needsCountryName ? longFormat.arg( languageName, countryName ) : languageName; + m_englishLabel = needsCountryName ? longFormat.arg( englishName, territoryName( m_locale ) ) : englishName; +} + +QLocale +Translation::getLocale( const Id& localeId ) +{ + const QString& localeName = localeId.name; + if ( localeName.isEmpty() ) + { + return QLocale(); + } + + auto it = std::find_if( std::cbegin( special_cases ), + std::cend( special_cases ), + [ &localeId ]( const TranslationSpecialCase& s ) { return localeId.name == s.id; } ); + if ( it != std::cend( special_cases ) && it->customLocale() ) + { + return QLocale( it->language, it->script, it->country ); + } + return QLocale( localeName ); +} + +} // namespace Locale +} // namespace Calamares diff --git a/calamares/src/libcalamares/locale/Translation.h b/calamares/src/libcalamares/locale/Translation.h new file mode 100644 index 0000000..14fb33c --- /dev/null +++ b/calamares/src/libcalamares/locale/Translation.h @@ -0,0 +1,145 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef LOCALE_TRANSLATION_H +#define LOCALE_TRANSLATION_H + +#include "utils/Logger.h" + +#include +#include +#include + +///@brief Define to 1 if the Qt version being used supports Interlingue fully +#if QT_VERSION < QT_VERSION_CHECK( 6, 7, 0 ) +#define CALAMARES_QT_SUPPORT_INTERLINGUE 0 +#else +#define CALAMARES_QT_SUPPORT_INTERLINGUE 1 +#endif + +namespace Calamares +{ +namespace Locale +{ + +/** + * @brief Consistent locale (language + country) naming. + * + * Support class to turn locale names (as used by Calamares's + * translation system) into QLocales, and also into consistent + * human-readable text labels. + * + * This handles special-cases in Calamares translations: + * - `sr@latin` is the name which Qt recognizes as `sr@latn`, + * Serbian written with Latin characters (not Cyrillic). + * - `ca@valencia` is the Catalan dialect spoken in Valencia. + * There is no Qt code for it. + * + * (There are more special cases, not documented here) + */ +class DLLEXPORT Translation : public QObject +{ + Q_OBJECT + +public: + /** @brief Formatting option for label -- add (country) to label. */ + enum class LabelFormat + { + AlwaysWithCountry, + IfNeededWithCountry + }; + + struct Id + { + QString name; + }; + + /** @brief Empty locale. This uses the system-default locale. */ + Translation( QObject* parent = nullptr ); + + /** @brief Construct from a locale name. + * + * The @p localeName should be one that Qt recognizes, e.g. en_US or ar_EY. + * The @p format determines whether the country name is always present + * in the label (human-readable form) or only if needed for disambiguation. + */ + Translation( const Id& localeId, LabelFormat format = LabelFormat::IfNeededWithCountry, QObject* parent = nullptr ); + + /** @brief Define a sorting order. + * + * Locales are sorted by their id, which means the ISO 2-letter code + country. + */ + bool operator<( const Translation& other ) const { return m_localeId < other.m_localeId; } + + /** @brief Is this locale English? + * + * en_US and en (American English) is defined as English. The Queen's + * English -- proper English -- is relegated to non-English status. + */ + bool isEnglish() const { return m_localeId == QLatin1String( "en_US" ) || m_localeId == QLatin1String( "en" ); } + + /** @brief Get the human-readable name for this locale. */ + QString label() const { return m_label; } + /** @brief Get the *English* human-readable name for this locale. */ + QString englishLabel() const { return m_englishLabel; } + + /** @brief Get the Qt locale. */ + QLocale locale() const { return m_locale; } + + /** @brief Gets the Calamares internal name (code) of the locale. + * + * This is a strongly-typed return to avoid it ending up all over + * the place as a QString. + */ + Id id() const { return { m_localeId }; } + + /// @brief Convenience accessor to the language part of the locale + QLocale::Language language() const { return m_locale.language(); } + + /// @brief Convenience accessor to the country part (if any) of the locale + QLocale::Country country() const + { +#if QT_VERSION < QT_VERSION_CHECK( 6, 6, 0 ) + + return m_locale.country(); +#else + return m_locale.territory(); +#endif + } + + /** @brief Get a Qt locale for the given @p localeName + * + * This obeys special cases as described in the class documentation. + */ + static QLocale getLocale( const Id& localeId ); + +private: + QLocale m_locale; + QString m_localeId; // the locale identifier, e.g. "en_GB" + QString m_label; // the native name of the locale + QString m_englishLabel; +}; + +static inline QDebug& +operator<<( QDebug& s, const Translation::Id& id ) +{ + return s << id.name; +} +static inline bool +operator==( const Translation::Id& lhs, const Translation::Id& rhs ) +{ + return lhs.name == rhs.name; +} + +} // namespace Locale +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/locale/TranslationsModel.cpp b/calamares/src/libcalamares/locale/TranslationsModel.cpp new file mode 100644 index 0000000..81380bf --- /dev/null +++ b/calamares/src/libcalamares/locale/TranslationsModel.cpp @@ -0,0 +1,155 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Camilo Higuita + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "TranslationsModel.h" + +#include "Lookup.h" + +#include "CalamaresTranslations.cc" // For the list of translations, generated at build time + +namespace Calamares +{ +namespace Locale +{ + +TranslationsModel::TranslationsModel( const QStringList& locales, QObject* parent ) + : QAbstractListModel( parent ) + , m_localeIds( locales ) +{ + Q_ASSERT( locales.count() > 0 ); + m_locales.reserve( locales.count() ); + + for ( const auto& l : locales ) + { + m_locales.push_back( new Translation( { l }, Translation::LabelFormat::IfNeededWithCountry, this ) ); + } +} + +TranslationsModel::~TranslationsModel() {} + +int +TranslationsModel::rowCount( const QModelIndex& ) const +{ + return m_locales.count(); +} + +QVariant +TranslationsModel::data( const QModelIndex& index, int role ) const +{ + if ( ( role != LabelRole ) && ( role != EnglishLabelRole ) ) + { + return QVariant(); + } + + if ( !index.isValid() ) + { + return QVariant(); + } + + const auto& locale = m_locales.at( index.row() ); + switch ( role ) + { + case LabelRole: + return locale->label(); + case EnglishLabelRole: + return locale->englishLabel(); + default: + return QVariant(); + } +} + +QHash< int, QByteArray > +TranslationsModel::roleNames() const +{ + return { { LabelRole, "label" }, { EnglishLabelRole, "englishLabel" } }; +} + +const Translation& +TranslationsModel::locale( int row ) const +{ + if ( ( row < 0 ) || ( row >= m_locales.count() ) ) + { + for ( const auto& l : m_locales ) + { + if ( l->isEnglish() ) + { + return *l; + } + } + return *m_locales[ 0 ]; + } + return *m_locales[ row ]; +} + +int +TranslationsModel::find( std::function< bool( const Translation& ) > predicate ) const +{ + for ( int row = 0; row < m_locales.count(); ++row ) + { + if ( predicate( *m_locales[ row ] ) ) + { + return row; + } + } + return -1; +} + +int +TranslationsModel::find( std::function< bool( const QLocale& ) > predicate ) const +{ + return find( [ & ]( const Translation& l ) { return predicate( l.locale() ); } ); +} + +int +TranslationsModel::find( const QLocale& locale ) const +{ + return find( [ & ]( const Translation& l ) { return locale == l.locale(); } ); +} + +int +TranslationsModel::find( const QString& countryCode ) const +{ + if ( countryCode.length() != 2 ) + { + return -1; + } + + auto c_l = countryData( countryCode ); + int r = find( [ & ]( const Translation& l ) + { return ( l.language() == c_l.second ) && ( l.country() == c_l.first ); } ); + if ( r >= 0 ) + { + return r; + } + return find( [ & ]( const Translation& l ) { return l.language() == c_l.second; } ); +} + +int +TranslationsModel::find( const Translation::Id& id ) const +{ + return find( [ & ]( const Translation& l ) { return l.id() == id; } ); +} + +TranslationsModel* +availableTranslations() +{ + static TranslationsModel* model = new TranslationsModel( availableLanguageList ); + return model; +} + +const QStringList& +availableLanguages() +{ + return availableLanguageList; +} + +} // namespace Locale +} // namespace Calamares diff --git a/calamares/src/libcalamares/locale/TranslationsModel.h b/calamares/src/libcalamares/locale/TranslationsModel.h new file mode 100644 index 0000000..0fdad03 --- /dev/null +++ b/calamares/src/libcalamares/locale/TranslationsModel.h @@ -0,0 +1,94 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Camilo Higuita + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef LOCALE_TRANSLATIONSMODEL_H +#define LOCALE_TRANSLATIONSMODEL_H + +#include "DllMacro.h" +#include "Translation.h" + +#include +#include + +namespace Calamares +{ +namespace Locale +{ + +class DLLEXPORT TranslationsModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum + { + LabelRole = Qt::DisplayRole, + EnglishLabelRole = Qt::UserRole + 1 + }; + + TranslationsModel( const QStringList& locales, QObject* parent = nullptr ); + ~TranslationsModel() override; + + int rowCount( const QModelIndex& parent ) const override; + + QVariant data( const QModelIndex& index, int role ) const override; + QHash< int, QByteArray > roleNames() const override; + + /** @brief Gets locale information for entry #n + * + * This is the backing data for the model; if @p row is out-of-range, + * returns a reference to en_US. + */ + const Translation& locale( int row ) const; + + /// @brief Returns all of the locale Ids (e.g. en_US) put into this model. + const QStringList& localeIds() const { return m_localeIds; } + + /** @brief Searches for an item that matches @p predicate + * + * Returns the row number of the first match, or -1 if there isn't one. + */ + int find( std::function< bool( const QLocale& ) > predicate ) const; + int find( std::function< bool( const Translation& ) > predicate ) const; + /// @brief Looks for an item using the same locale, -1 if there isn't one + int find( const QLocale& ) const; + /// @brief Looks for an item that best matches the 2-letter country code + int find( const QString& countryCode ) const; + /// @brief Looks up a translation Id + int find( const Translation::Id& id ) const; + +private: + QVector< Translation* > m_locales; + QStringList m_localeIds; +}; + +/** @brief Returns a model with all available translations. + * + * The translations are set when Calamares is compiled; the list + * of names used can be queried with avalableLanguages(). + * + * This model is a singleton and can be shared. + * + * NOTE: While the model is not typed const, it should be. Do not modify. + */ +DLLEXPORT TranslationsModel* availableTranslations(); + +/** @brief The list of names (e.g. en, pt_BR) of available translations. + * + * The translations are set when Calamares is compiled. + * At CMake-time, the list CALAMARES_TRANSLATION_LANGUAGES + * is used to create the table. + */ +DLLEXPORT const QStringList& availableLanguages(); + +} // namespace Locale +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/locale/ZoneData_p.cxxtr b/calamares/src/libcalamares/locale/ZoneData_p.cxxtr new file mode 100644 index 0000000..c59c60d --- /dev/null +++ b/calamares/src/libcalamares/locale/ZoneData_p.cxxtr @@ -0,0 +1,480 @@ +/* GENERATED FILE DO NOT EDIT +* +* SPDX-FileCopyrightText: 2009 Arthur David Olson +* SPDX-FileCopyrightText: 2019 Adriaan de Groot +* SPDX-License-Identifier: CC0-1.0 +* +* This file is derived from zone.tab, which has its own copyright statement: +* +* This file is in the public domain, so clarified as of +* 2009-05-17 by Arthur David Olson. +* +* From Paul Eggert (2018-06-27): +* This file is intended as a backward-compatibility aid for older programs. +* New programs should use zone1970.tab. This file is like zone1970.tab (see +* zone1970.tab's comments), but with the following additional restrictions: +* +* 1. This file contains only ASCII characters. +* 2. The first data column contains exactly one country code. +* +*/ + +/** THIS FILE EXISTS ONLY FOR TRANSLATIONS PURPOSES **/ + +// *INDENT-OFF* +// clang-format off +/* This returns a reference to local, which is a terrible idea. + * Good thing it's not meant to be compiled. + */ +static const QStringList& tz_regions_table() +{ + return QStringList { + QObject::tr("Africa", "tz_regions"), + QObject::tr("America", "tz_regions"), + QObject::tr("Antarctica", "tz_regions"), + QObject::tr("Arctic", "tz_regions"), + QObject::tr("Asia", "tz_regions"), + QObject::tr("Atlantic", "tz_regions"), + QObject::tr("Australia", "tz_regions"), + QObject::tr("Europe", "tz_regions"), + QObject::tr("Indian", "tz_regions"), + QObject::tr("Pacific", "tz_regions"), + QString() + }; +} + +/* This returns a reference to local, which is a terrible idea. + * Good thing it's not meant to be compiled. + */ +static const QStringList& tz_names_table() +{ + return QStringList { + QObject::tr("Abidjan", "tz_names"), + QObject::tr("Accra", "tz_names"), + QObject::tr("Adak", "tz_names"), + QObject::tr("Addis Ababa", "tz_names"), + QObject::tr("Adelaide", "tz_names"), + QObject::tr("Aden", "tz_names"), + QObject::tr("Algiers", "tz_names"), + QObject::tr("Almaty", "tz_names"), + QObject::tr("Amman", "tz_names"), + QObject::tr("Amsterdam", "tz_names"), + QObject::tr("Anadyr", "tz_names"), + QObject::tr("Anchorage", "tz_names"), + QObject::tr("Andorra", "tz_names"), + QObject::tr("Anguilla", "tz_names"), + QObject::tr("Antananarivo", "tz_names"), + QObject::tr("Antigua", "tz_names"), + QObject::tr("Apia", "tz_names"), + QObject::tr("Aqtau", "tz_names"), + QObject::tr("Aqtobe", "tz_names"), + QObject::tr("Araguaina", "tz_names"), + QObject::tr("Argentina/Buenos Aires", "tz_names"), + QObject::tr("Argentina/Catamarca", "tz_names"), + QObject::tr("Argentina/Cordoba", "tz_names"), + QObject::tr("Argentina/Jujuy", "tz_names"), + QObject::tr("Argentina/La Rioja", "tz_names"), + QObject::tr("Argentina/Mendoza", "tz_names"), + QObject::tr("Argentina/Rio Gallegos", "tz_names"), + QObject::tr("Argentina/Salta", "tz_names"), + QObject::tr("Argentina/San Juan", "tz_names"), + QObject::tr("Argentina/San Luis", "tz_names"), + QObject::tr("Argentina/Tucuman", "tz_names"), + QObject::tr("Argentina/Ushuaia", "tz_names"), + QObject::tr("Aruba", "tz_names"), + QObject::tr("Ashgabat", "tz_names"), + QObject::tr("Asmara", "tz_names"), + QObject::tr("Astrakhan", "tz_names"), + QObject::tr("Asuncion", "tz_names"), + QObject::tr("Athens", "tz_names"), + QObject::tr("Atikokan", "tz_names"), + QObject::tr("Atyrau", "tz_names"), + QObject::tr("Auckland", "tz_names"), + QObject::tr("Azores", "tz_names"), + QObject::tr("Baghdad", "tz_names"), + QObject::tr("Bahia", "tz_names"), + QObject::tr("Bahia Banderas", "tz_names"), + QObject::tr("Bahrain", "tz_names"), + QObject::tr("Baku", "tz_names"), + QObject::tr("Bamako", "tz_names"), + QObject::tr("Bangkok", "tz_names"), + QObject::tr("Bangui", "tz_names"), + QObject::tr("Banjul", "tz_names"), + QObject::tr("Barbados", "tz_names"), + QObject::tr("Barnaul", "tz_names"), + QObject::tr("Beirut", "tz_names"), + QObject::tr("Belem", "tz_names"), + QObject::tr("Belgrade", "tz_names"), + QObject::tr("Belize", "tz_names"), + QObject::tr("Berlin", "tz_names"), + QObject::tr("Bermuda", "tz_names"), + QObject::tr("Bishkek", "tz_names"), + QObject::tr("Bissau", "tz_names"), + QObject::tr("Blanc-Sablon", "tz_names"), + QObject::tr("Blantyre", "tz_names"), + QObject::tr("Boa Vista", "tz_names"), + QObject::tr("Bogota", "tz_names"), + QObject::tr("Boise", "tz_names"), + QObject::tr("Bougainville", "tz_names"), + QObject::tr("Bratislava", "tz_names"), + QObject::tr("Brazzaville", "tz_names"), + QObject::tr("Brisbane", "tz_names"), + QObject::tr("Broken Hill", "tz_names"), + QObject::tr("Brunei", "tz_names"), + QObject::tr("Brussels", "tz_names"), + QObject::tr("Bucharest", "tz_names"), + QObject::tr("Budapest", "tz_names"), + QObject::tr("Bujumbura", "tz_names"), + QObject::tr("Busingen", "tz_names"), + QObject::tr("Cairo", "tz_names"), + QObject::tr("Cambridge Bay", "tz_names"), + QObject::tr("Campo Grande", "tz_names"), + QObject::tr("Canary", "tz_names"), + QObject::tr("Cancun", "tz_names"), + QObject::tr("Cape Verde", "tz_names"), + QObject::tr("Caracas", "tz_names"), + QObject::tr("Casablanca", "tz_names"), + QObject::tr("Casey", "tz_names"), + QObject::tr("Cayenne", "tz_names"), + QObject::tr("Cayman", "tz_names"), + QObject::tr("Ceuta", "tz_names"), + QObject::tr("Chagos", "tz_names"), + QObject::tr("Chatham", "tz_names"), + QObject::tr("Chicago", "tz_names"), + QObject::tr("Chihuahua", "tz_names"), + QObject::tr("Chisinau", "tz_names"), + QObject::tr("Chita", "tz_names"), + QObject::tr("Choibalsan", "tz_names"), + QObject::tr("Christmas", "tz_names"), + QObject::tr("Chuuk", "tz_names"), + QObject::tr("Cocos", "tz_names"), + QObject::tr("Colombo", "tz_names"), + QObject::tr("Comoro", "tz_names"), + QObject::tr("Conakry", "tz_names"), + QObject::tr("Copenhagen", "tz_names"), + QObject::tr("Costa Rica", "tz_names"), + QObject::tr("Creston", "tz_names"), + QObject::tr("Cuiaba", "tz_names"), + QObject::tr("Curacao", "tz_names"), + QObject::tr("Currie", "tz_names"), + QObject::tr("Dakar", "tz_names"), + QObject::tr("Damascus", "tz_names"), + QObject::tr("Danmarkshavn", "tz_names"), + QObject::tr("Dar es Salaam", "tz_names"), + QObject::tr("Darwin", "tz_names"), + QObject::tr("Davis", "tz_names"), + QObject::tr("Dawson", "tz_names"), + QObject::tr("Dawson Creek", "tz_names"), + QObject::tr("Denver", "tz_names"), + QObject::tr("Detroit", "tz_names"), + QObject::tr("Dhaka", "tz_names"), + QObject::tr("Dili", "tz_names"), + QObject::tr("Djibouti", "tz_names"), + QObject::tr("Dominica", "tz_names"), + QObject::tr("Douala", "tz_names"), + QObject::tr("Dubai", "tz_names"), + QObject::tr("Dublin", "tz_names"), + QObject::tr("DumontDUrville", "tz_names"), + QObject::tr("Dushanbe", "tz_names"), + QObject::tr("Easter", "tz_names"), + QObject::tr("Edmonton", "tz_names"), + QObject::tr("Efate", "tz_names"), + QObject::tr("Eirunepe", "tz_names"), + QObject::tr("El Aaiun", "tz_names"), + QObject::tr("El Salvador", "tz_names"), + QObject::tr("Enderbury", "tz_names"), + QObject::tr("Eucla", "tz_names"), + QObject::tr("Fakaofo", "tz_names"), + QObject::tr("Famagusta", "tz_names"), + QObject::tr("Faroe", "tz_names"), + QObject::tr("Fiji", "tz_names"), + QObject::tr("Fort Nelson", "tz_names"), + QObject::tr("Fortaleza", "tz_names"), + QObject::tr("Freetown", "tz_names"), + QObject::tr("Funafuti", "tz_names"), + QObject::tr("Gaborone", "tz_names"), + QObject::tr("Galapagos", "tz_names"), + QObject::tr("Gambier", "tz_names"), + QObject::tr("Gaza", "tz_names"), + QObject::tr("Gibraltar", "tz_names"), + QObject::tr("Glace Bay", "tz_names"), + QObject::tr("Godthab", "tz_names"), + QObject::tr("Goose Bay", "tz_names"), + QObject::tr("Grand Turk", "tz_names"), + QObject::tr("Grenada", "tz_names"), + QObject::tr("Guadalcanal", "tz_names"), + QObject::tr("Guadeloupe", "tz_names"), + QObject::tr("Guam", "tz_names"), + QObject::tr("Guatemala", "tz_names"), + QObject::tr("Guayaquil", "tz_names"), + QObject::tr("Guernsey", "tz_names"), + QObject::tr("Guyana", "tz_names"), + QObject::tr("Halifax", "tz_names"), + QObject::tr("Harare", "tz_names"), + QObject::tr("Havana", "tz_names"), + QObject::tr("Hebron", "tz_names"), + QObject::tr("Helsinki", "tz_names"), + QObject::tr("Hermosillo", "tz_names"), + QObject::tr("Ho Chi Minh", "tz_names"), + QObject::tr("Hobart", "tz_names"), + QObject::tr("Hong Kong", "tz_names"), + QObject::tr("Honolulu", "tz_names"), + QObject::tr("Hovd", "tz_names"), + QObject::tr("Indiana/Indianapolis", "tz_names"), + QObject::tr("Indiana/Knox", "tz_names"), + QObject::tr("Indiana/Marengo", "tz_names"), + QObject::tr("Indiana/Petersburg", "tz_names"), + QObject::tr("Indiana/Tell City", "tz_names"), + QObject::tr("Indiana/Vevay", "tz_names"), + QObject::tr("Indiana/Vincennes", "tz_names"), + QObject::tr("Indiana/Winamac", "tz_names"), + QObject::tr("Inuvik", "tz_names"), + QObject::tr("Iqaluit", "tz_names"), + QObject::tr("Irkutsk", "tz_names"), + QObject::tr("Isle of Man", "tz_names"), + QObject::tr("Istanbul", "tz_names"), + QObject::tr("Jakarta", "tz_names"), + QObject::tr("Jamaica", "tz_names"), + QObject::tr("Jayapura", "tz_names"), + QObject::tr("Jersey", "tz_names"), + QObject::tr("Jerusalem", "tz_names"), + QObject::tr("Johannesburg", "tz_names"), + QObject::tr("Juba", "tz_names"), + QObject::tr("Juneau", "tz_names"), + QObject::tr("Kabul", "tz_names"), + QObject::tr("Kaliningrad", "tz_names"), + QObject::tr("Kamchatka", "tz_names"), + QObject::tr("Kampala", "tz_names"), + QObject::tr("Karachi", "tz_names"), + QObject::tr("Kathmandu", "tz_names"), + QObject::tr("Kentucky/Louisville", "tz_names"), + QObject::tr("Kentucky/Monticello", "tz_names"), + QObject::tr("Kerguelen", "tz_names"), + QObject::tr("Khandyga", "tz_names"), + QObject::tr("Khartoum", "tz_names"), + QObject::tr("Kiev", "tz_names"), + QObject::tr("Kigali", "tz_names"), + QObject::tr("Kinshasa", "tz_names"), + QObject::tr("Kiritimati", "tz_names"), + QObject::tr("Kirov", "tz_names"), + QObject::tr("Kolkata", "tz_names"), + QObject::tr("Kosrae", "tz_names"), + QObject::tr("Kralendijk", "tz_names"), + QObject::tr("Krasnoyarsk", "tz_names"), + QObject::tr("Kuala Lumpur", "tz_names"), + QObject::tr("Kuching", "tz_names"), + QObject::tr("Kuwait", "tz_names"), + QObject::tr("Kwajalein", "tz_names"), + QObject::tr("La Paz", "tz_names"), + QObject::tr("Lagos", "tz_names"), + QObject::tr("Libreville", "tz_names"), + QObject::tr("Lima", "tz_names"), + QObject::tr("Lindeman", "tz_names"), + QObject::tr("Lisbon", "tz_names"), + QObject::tr("Ljubljana", "tz_names"), + QObject::tr("Lome", "tz_names"), + QObject::tr("London", "tz_names"), + QObject::tr("Longyearbyen", "tz_names"), + QObject::tr("Lord Howe", "tz_names"), + QObject::tr("Los Angeles", "tz_names"), + QObject::tr("Lower Princes", "tz_names"), + QObject::tr("Luanda", "tz_names"), + QObject::tr("Lubumbashi", "tz_names"), + QObject::tr("Lusaka", "tz_names"), + QObject::tr("Luxembourg", "tz_names"), + QObject::tr("Macau", "tz_names"), + QObject::tr("Maceio", "tz_names"), + QObject::tr("Macquarie", "tz_names"), + QObject::tr("Madeira", "tz_names"), + QObject::tr("Madrid", "tz_names"), + QObject::tr("Magadan", "tz_names"), + QObject::tr("Mahe", "tz_names"), + QObject::tr("Majuro", "tz_names"), + QObject::tr("Makassar", "tz_names"), + QObject::tr("Malabo", "tz_names"), + QObject::tr("Maldives", "tz_names"), + QObject::tr("Malta", "tz_names"), + QObject::tr("Managua", "tz_names"), + QObject::tr("Manaus", "tz_names"), + QObject::tr("Manila", "tz_names"), + QObject::tr("Maputo", "tz_names"), + QObject::tr("Mariehamn", "tz_names"), + QObject::tr("Marigot", "tz_names"), + QObject::tr("Marquesas", "tz_names"), + QObject::tr("Martinique", "tz_names"), + QObject::tr("Maseru", "tz_names"), + QObject::tr("Matamoros", "tz_names"), + QObject::tr("Mauritius", "tz_names"), + QObject::tr("Mawson", "tz_names"), + QObject::tr("Mayotte", "tz_names"), + QObject::tr("Mazatlan", "tz_names"), + QObject::tr("Mbabane", "tz_names"), + QObject::tr("McMurdo", "tz_names"), + QObject::tr("Melbourne", "tz_names"), + QObject::tr("Menominee", "tz_names"), + QObject::tr("Merida", "tz_names"), + QObject::tr("Metlakatla", "tz_names"), + QObject::tr("Mexico City", "tz_names"), + QObject::tr("Midway", "tz_names"), + QObject::tr("Minsk", "tz_names"), + QObject::tr("Miquelon", "tz_names"), + QObject::tr("Mogadishu", "tz_names"), + QObject::tr("Monaco", "tz_names"), + QObject::tr("Moncton", "tz_names"), + QObject::tr("Monrovia", "tz_names"), + QObject::tr("Monterrey", "tz_names"), + QObject::tr("Montevideo", "tz_names"), + QObject::tr("Montserrat", "tz_names"), + QObject::tr("Moscow", "tz_names"), + QObject::tr("Muscat", "tz_names"), + QObject::tr("Nairobi", "tz_names"), + QObject::tr("Nassau", "tz_names"), + QObject::tr("Nauru", "tz_names"), + QObject::tr("Ndjamena", "tz_names"), + QObject::tr("New York", "tz_names"), + QObject::tr("Niamey", "tz_names"), + QObject::tr("Nicosia", "tz_names"), + QObject::tr("Nipigon", "tz_names"), + QObject::tr("Niue", "tz_names"), + QObject::tr("Nome", "tz_names"), + QObject::tr("Norfolk", "tz_names"), + QObject::tr("Noronha", "tz_names"), + QObject::tr("North Dakota/Beulah", "tz_names"), + QObject::tr("North Dakota/Center", "tz_names"), + QObject::tr("North Dakota/New Salem", "tz_names"), + QObject::tr("Nouakchott", "tz_names"), + QObject::tr("Noumea", "tz_names"), + QObject::tr("Novokuznetsk", "tz_names"), + QObject::tr("Novosibirsk", "tz_names"), + QObject::tr("Ojinaga", "tz_names"), + QObject::tr("Omsk", "tz_names"), + QObject::tr("Oral", "tz_names"), + QObject::tr("Oslo", "tz_names"), + QObject::tr("Ouagadougou", "tz_names"), + QObject::tr("Pago Pago", "tz_names"), + QObject::tr("Palau", "tz_names"), + QObject::tr("Palmer", "tz_names"), + QObject::tr("Panama", "tz_names"), + QObject::tr("Pangnirtung", "tz_names"), + QObject::tr("Paramaribo", "tz_names"), + QObject::tr("Paris", "tz_names"), + QObject::tr("Perth", "tz_names"), + QObject::tr("Phnom Penh", "tz_names"), + QObject::tr("Phoenix", "tz_names"), + QObject::tr("Pitcairn", "tz_names"), + QObject::tr("Podgorica", "tz_names"), + QObject::tr("Pohnpei", "tz_names"), + QObject::tr("Pontianak", "tz_names"), + QObject::tr("Port Moresby", "tz_names"), + QObject::tr("Port of Spain", "tz_names"), + QObject::tr("Port-au-Prince", "tz_names"), + QObject::tr("Porto Velho", "tz_names"), + QObject::tr("Porto-Novo", "tz_names"), + QObject::tr("Prague", "tz_names"), + QObject::tr("Puerto Rico", "tz_names"), + QObject::tr("Punta Arenas", "tz_names"), + QObject::tr("Pyongyang", "tz_names"), + QObject::tr("Qatar", "tz_names"), + QObject::tr("Qostanay", "tz_names"), + QObject::tr("Qyzylorda", "tz_names"), + QObject::tr("Rainy River", "tz_names"), + QObject::tr("Rankin Inlet", "tz_names"), + QObject::tr("Rarotonga", "tz_names"), + QObject::tr("Recife", "tz_names"), + QObject::tr("Regina", "tz_names"), + QObject::tr("Resolute", "tz_names"), + QObject::tr("Reunion", "tz_names"), + QObject::tr("Reykjavik", "tz_names"), + QObject::tr("Riga", "tz_names"), + QObject::tr("Rio Branco", "tz_names"), + QObject::tr("Riyadh", "tz_names"), + QObject::tr("Rome", "tz_names"), + QObject::tr("Rothera", "tz_names"), + QObject::tr("Saipan", "tz_names"), + QObject::tr("Sakhalin", "tz_names"), + QObject::tr("Samara", "tz_names"), + QObject::tr("Samarkand", "tz_names"), + QObject::tr("San Marino", "tz_names"), + QObject::tr("Santarem", "tz_names"), + QObject::tr("Santiago", "tz_names"), + QObject::tr("Santo Domingo", "tz_names"), + QObject::tr("Sao Paulo", "tz_names"), + QObject::tr("Sao Tome", "tz_names"), + QObject::tr("Sarajevo", "tz_names"), + QObject::tr("Saratov", "tz_names"), + QObject::tr("Scoresbysund", "tz_names"), + QObject::tr("Seoul", "tz_names"), + QObject::tr("Shanghai", "tz_names"), + QObject::tr("Simferopol", "tz_names"), + QObject::tr("Singapore", "tz_names"), + QObject::tr("Sitka", "tz_names"), + QObject::tr("Skopje", "tz_names"), + QObject::tr("Sofia", "tz_names"), + QObject::tr("South Georgia", "tz_names"), + QObject::tr("Srednekolymsk", "tz_names"), + QObject::tr("St Barthelemy", "tz_names"), + QObject::tr("St Helena", "tz_names"), + QObject::tr("St Johns", "tz_names"), + QObject::tr("St Kitts", "tz_names"), + QObject::tr("St Lucia", "tz_names"), + QObject::tr("St Thomas", "tz_names"), + QObject::tr("St Vincent", "tz_names"), + QObject::tr("Stanley", "tz_names"), + QObject::tr("Stockholm", "tz_names"), + QObject::tr("Swift Current", "tz_names"), + QObject::tr("Sydney", "tz_names"), + QObject::tr("Syowa", "tz_names"), + QObject::tr("Tahiti", "tz_names"), + QObject::tr("Taipei", "tz_names"), + QObject::tr("Tallinn", "tz_names"), + QObject::tr("Tarawa", "tz_names"), + QObject::tr("Tashkent", "tz_names"), + QObject::tr("Tbilisi", "tz_names"), + QObject::tr("Tegucigalpa", "tz_names"), + QObject::tr("Tehran", "tz_names"), + QObject::tr("Thimphu", "tz_names"), + QObject::tr("Thule", "tz_names"), + QObject::tr("Thunder Bay", "tz_names"), + QObject::tr("Tijuana", "tz_names"), + QObject::tr("Tirane", "tz_names"), + QObject::tr("Tokyo", "tz_names"), + QObject::tr("Tomsk", "tz_names"), + QObject::tr("Tongatapu", "tz_names"), + QObject::tr("Toronto", "tz_names"), + QObject::tr("Tortola", "tz_names"), + QObject::tr("Tripoli", "tz_names"), + QObject::tr("Troll", "tz_names"), + QObject::tr("Tunis", "tz_names"), + QObject::tr("Ulaanbaatar", "tz_names"), + QObject::tr("Ulyanovsk", "tz_names"), + QObject::tr("Urumqi", "tz_names"), + QObject::tr("Ust-Nera", "tz_names"), + QObject::tr("Uzhgorod", "tz_names"), + QObject::tr("Vaduz", "tz_names"), + QObject::tr("Vancouver", "tz_names"), + QObject::tr("Vatican", "tz_names"), + QObject::tr("Vienna", "tz_names"), + QObject::tr("Vientiane", "tz_names"), + QObject::tr("Vilnius", "tz_names"), + QObject::tr("Vladivostok", "tz_names"), + QObject::tr("Volgograd", "tz_names"), + QObject::tr("Vostok", "tz_names"), + QObject::tr("Wake", "tz_names"), + QObject::tr("Wallis", "tz_names"), + QObject::tr("Warsaw", "tz_names"), + QObject::tr("Whitehorse", "tz_names"), + QObject::tr("Windhoek", "tz_names"), + QObject::tr("Winnipeg", "tz_names"), + QObject::tr("Yakutat", "tz_names"), + QObject::tr("Yakutsk", "tz_names"), + QObject::tr("Yangon", "tz_names"), + QObject::tr("Yekaterinburg", "tz_names"), + QObject::tr("Yellowknife", "tz_names"), + QObject::tr("Yerevan", "tz_names"), + QObject::tr("Zagreb", "tz_names"), + QObject::tr("Zaporozhye", "tz_names"), + QObject::tr("Zurich", "tz_names"), + QString() + }; +} + diff --git a/calamares/src/libcalamares/locale/cldr-extractor.py b/calamares/src/libcalamares/locale/cldr-extractor.py new file mode 100644 index 0000000..7aff85b --- /dev/null +++ b/calamares/src/libcalamares/locale/cldr-extractor.py @@ -0,0 +1,271 @@ +#! /usr/bin/env python3 +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +""" +Python3 script to scrape some data out of ICU CLDR supplemental data. + +To use this script, you must have downloaded the CLDR data, e.g. +http://unicode.org/Public/cldr/35.1/, and extracted the zip file. +Run the script from **inside** the common/ durectory that is created +(or fix the hard-coded path). + +The script tries to print C++ code that compiles; if there are encoding +problems, it will print some kind of representation of the problematic +lines. + +To avoid having to cross-reference multiple XML files, the script +cheats: it reads the comments as well to get names. So it looks for +pairs of lines like this: + + + + +It extracts the 2-character country code "BQ" from the sub-tag, and +parses the comment to get a language and country name (instead of looking up +"pap" and "BQ" in other tables). This may be considered a hack. + +A large collection of exceptions can be found in the two *_mapper tables, +which massage the CLDR names to Qt enum values. +""" +# +### END USAGE + +import sys + +# These are languages listed in CLDR that don't match +# the enum-values in QLocale::Language. +language_mapper = { + "?" : "AnyLanguage", + "Bangla" : "Bengali", + "Kalaallisut" : "Greenlandic", + "Haitian Creole" : "Haitian", + "Kyrgyz" : "Kirghiz", + "Norwegian Bokmål" : "NorwegianBokmal", + "Tokelau" : "TokelauLanguage", + "Tuvalu" : "TuvaluLanguage", + } + +country_mapper = { + "Åland Islands" : "AlandIslands", + "St. Barthélemy" : "SaintBarthelemy", + "Côte d’Ivoire" : "IvoryCoast", + "Curaçao" : "CuraSao", + "Réunion" : "Reunion", + "São Tomé & Príncipe" : "SaoTomeAndPrincipe", + "Bosnia & Herzegovina" : "BosniaAndHerzegowina", + "Czechia" : "CzechRepublic", + "St. Pierre & Miquelon" : "SaintPierreAndMiquelon", + "Vatican City" : "VaticanCityState", + "South Georgia & South Sandwich Islands" : "SouthGeorgiaAndTheSouthSandwichIslands", + "Timor-Leste" : "EastTimor", + "Wallis & Futuna" : "WallisAndFutunaIslands", + "Myanmar (Burma)" : "Myanmar", + "Svalbard & Jan Mayen" : "SvalbardAndJanMayenIslands", + "St. Martin" : "SaintMartin", + "North Macedonia" : "Macedonia", + "Hong Kong SAR China" : "HongKong", + "Macao SAR China" : "Macau", + "Eurozone" : "AnyCountry", # Not likely for GeoIP + "Caribbean Netherlands" : "Bonaire", # Bonaire, Saba, St.Eustatius + } + +class CountryData: + def __init__(self, country_code, language_name, country_name): + """ + Takes a 2-letter country name, and enum names from + QLocale::Language and QLocale::Country. An empty + @p country code is acceptable, for the terminating + entry in the data array (and yields a 0,0 code). + """ + if country_code: + assert len(country_code) == 2 + self.country_code = country_code + self.language_enum = language_name + self.country_enum = country_name + else: + self.country_code = "" + self.language_enum = "AnyLanguage" + self.country_enum = "AnyCountry" + + def __str__(self): + if self.country_code: + char0 = "'{!s}'".format(self.country_code[0]) + char1 = "'{!s}'".format(self.country_code[1]) + else: + char0 = "0" + char1 = "0" + + return "{!s} QLocale::Language::{!s}, QLocale::Country::{!s}, {!s}, {!s} {!s},".format( + "{", + self.language_enum, + self.country_enum, + char0, + char1, + "}") + + # Must match type name below + cpp_classname = "CountryData" + + # Must match the output format of __str__ above + cpp_declaration = """ +struct CountryData +{ + QLocale::Language l; + QLocale::Country c; + char cc1; + char cc2; +}; +""" + + +def extricate_subtags(l1, l2): + """ + Given two lines @p l1 and @p l2 which are the element-line + and the comment-line underneath it, return a CountryData for them, + or None if the two lines are not relevant (e.g. not the right subtag from, + or 3-letter country codes. + """ + if 'from="und_' not in l1: + return + if '{ ?; ?;' not in l2: + return + + # This is extremely crude "parsing" which chops up the string + # by delimiter and then extracts some substring. + l1_parts = l1.split("und_") + l2_parts = l2.split(";") + + l1_first_quote = l1_parts[1].find('"') + l1_code = l1_parts[1][:l1_first_quote] + if len(l1_code) != 2: + return + + l2_brace = l2_parts[2].find("{") + l2_language = l2_parts[2][l2_brace+1:].strip() + l2_brace = l2_parts[2].find("}") + l2_country = l2_parts[2][:l2_brace-1].strip() + + # Handle mapped cases + l2_language = language_mapper.get(l2_language, l2_language) + l2_language = l2_language.replace(" ", "") + + # Handle mapped cases and then do a bunch of standard replacements. + l2_country = country_mapper.get(l2_country, l2_country) + l2_country = l2_country.replace(" ", "").replace("-", "").replace(".","").replace("&","And") + + return CountryData(l1_code, l2_language, l2_country) + + +def read_subtags_file(): + """ + Returns a list of CountryData objects from the likelySubtags file. + """ + data = [] + + with open("supplemental/likelySubtags.xml", "rt", encoding="UTF-8") as f: + l1 = "a line" + while l1: + l1 = f.readline() + if ' === +* +* SPDX-FileCopyrightText: 1991-2019 Unicode, Inc. +* SPDX-FileCopyrightText: 2019 Adriaan de Groot +* SPDX-License-Identifier: CC0 +* +* This file is derived from CLDR data from Unicode, Inc. Applicable terms +* are listed at http://unicode.org/copyright.html , of which the most +* important are: +* +* A. Unicode Copyright +* 1. Copyright © 1991-2019 Unicode, Inc. All rights reserved. +* B. Definitions +* Unicode Data Files ("DATA FILES") include all data files under the directories: +* https://www.unicode.org/Public/ +* C. Terms of Use +* 1. Certain documents and files on this website contain a legend indicating +* that "Modification is permitted." Any person is hereby authorized, +* without fee, to modify such documents and files to create derivative +* works conforming to the Unicode® Standard, subject to Terms and +* Conditions herein. +* 2. Any person is hereby authorized, without fee, to view, use, reproduce, +* and distribute all documents and files, subject to the Terms and +* Conditions herein. +*/ + +// BEGIN Generated from CLDR data +// *INDENT-OFF* +// clang-format off + +""" + +cpp_footer_comment = """ +// END Generated from CLDR data +""" + + +def make_identifier(classname): + """ + Given a class name (e.g. CountryData) return an identifer + for the data-table for that class. + """ + identifier = [ classname[0].lower() ] + for c in classname[1:]: + if c.isupper(): + identifier.extend(["_", c.lower()]) + else: + identifier.append(c) + + return "".join(identifier) + + +def export_class(cls, data): + """ + Given a @p cls and a list of @p data objects from that class, + print (to stdout) a C++ file for that data. + """ + identifier = make_identifier(cls.cpp_classname) + + with open("{!s}_p.cpp".format(cls.cpp_classname), "wt", encoding="UTF-8") as f: + f.write(cpp_header_comment) + f.write(cls.cpp_declaration) + f.write("\nstatic constexpr int const {!s}_size = {!s};\n".format( + identifier, + len(data))) + f.write("\nstatic const {!s} {!s}_table[] = {!s}\n".format( + cls.cpp_classname, + identifier, + "{")) + for d in data: + f.write(str(d)) + f.write("\n") + f.write("};\n\n"); + f.write("static_assert( (sizeof({!s}_table) / sizeof({!s})) == {!s}_size, \"Table size mismatch for {!s}\" );\n\n".format( + identifier, + cls.cpp_classname, + identifier, + cls.cpp_classname)) + f.write(cpp_footer_comment) + + +if __name__ == "__main__": + export_class(CountryData, read_subtags_file()) diff --git a/calamares/src/libcalamares/locale/zone-extractor.py b/calamares/src/libcalamares/locale/zone-extractor.py new file mode 100644 index 0000000..b3d9e19 --- /dev/null +++ b/calamares/src/libcalamares/locale/zone-extractor.py @@ -0,0 +1,83 @@ +#! /usr/bin/env python3 +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +""" +Python3 script to scrape some data out of zoneinfo/zone.tab. + +To use this script, you must have a zone.tab in a standard location, +/usr/share/zoneinfo/zone.tab (this is usual on FreeBSD and Linux). + +Prints out a few tables of zone names for use in translations. +""" + +def scrape_file(file, regionset, zoneset): + for line in file.readlines(): + if line.startswith("#"): + continue + parts = line.split("\t") + if len(parts) < 3: + continue + + zoneid = parts[2] + if not "/" in zoneid: + continue + + region, zone = zoneid.split("/", 1) + + zone = zone.strip().replace("_", " ") + + regionset.add(region) + assert(zone not in zoneset) + zoneset.add(zone) + +def write_set(file, label, set): + file.write("/* This returns a reference to local, which is a terrible idea.\n * Good thing it's not meant to be compiled.\n */\n") + # Note {{ is an escaped { for Python string formatting + file.write("static const QStringList& {!s}_table()\n{{\n\treturn QStringList {{\n".format(label)) + for x in sorted(set): + file.write("""\t\tQObject::tr("{!s}", "{!s}"),\n""".format(x, label)) + file.write("\t\tQString()\n\t};\n}\n\n") + +cpp_header_comment = """/* GENERATED FILE DO NOT EDIT +* +* === This file is part of Calamares - === +* +* SPDX-FileCopyrightText: 2009 Arthur David Olson +* SPDX-FileCopyrightText: 2019 Adriaan de Groot +* SPDX-License-Identifier: CC0-1.0 +* +* This file is derived from zone.tab, which has its own copyright statement: +* +* This file is in the public domain, so clarified as of +* 2009-05-17 by Arthur David Olson. +* +* From Paul Eggert (2018-06-27): +* This file is intended as a backward-compatibility aid for older programs. +* New programs should use zone1970.tab. This file is like zone1970.tab (see +* zone1970.tab's comments), but with the following additional restrictions: +* +* 1. This file contains only ASCII characters. +* 2. The first data column contains exactly one country code. +* +*/ + +/** THIS FILE EXISTS ONLY FOR TRANSLATIONS PURPOSES **/ + +// *INDENT-OFF* +// clang-format off +""" + +if __name__ == "__main__": + regions=set() + zones=set() + with open("/usr/share/zoneinfo/zone.tab", "r") as f: + scrape_file(f, regions, zones) + with open("ZoneData_p.cpp", "w") as f: + f.write(cpp_header_comment) + write_set(f, "tz_regions", regions) + write_set(f, "tz_names", zones) + diff --git a/calamares/src/libcalamares/modulesystem/Actions.h b/calamares/src/libcalamares/modulesystem/Actions.h new file mode 100644 index 0000000..f4bbe7e --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Actions.h @@ -0,0 +1,29 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef MODULESYSTEM_ACTIONS_H +#define MODULESYSTEM_ACTIONS_H + +namespace Calamares +{ +namespace ModuleSystem +{ + +enum class Action : char +{ + Show, + Exec +}; + +} // namespace ModuleSystem +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/modulesystem/Config.cpp b/calamares/src/libcalamares/modulesystem/Config.cpp new file mode 100644 index 0000000..1294c8d --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Config.cpp @@ -0,0 +1,135 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "Preset.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +namespace Calamares +{ +namespace ModuleSystem +{ + +class Config::Private +{ +public: + std::unique_ptr< Presets > m_presets; +}; + +Config::Config( QObject* parent ) + : QObject( parent ) + , d( std::make_unique< Private >() ) +{ +} + +Config::~Config() {} + +bool +Config::isEditable( const QString& fieldName ) const +{ + if ( m_unlocked ) + { + return true; + } + if ( d && d->m_presets ) + { + return d->m_presets->isEditable( fieldName ); + } + else + { + cWarning() << "Checking isEditable, but no presets are configured."; + } + return true; +} + +Config::ApplyPresets::ApplyPresets( Calamares::ModuleSystem::Config& c, const QVariantMap& configurationMap ) + : m_c( c ) + , m_bogus( true ) + , m_map( Calamares::getSubMap( configurationMap, "presets", m_bogus ) ) +{ + c.m_unlocked = true; + if ( !c.d->m_presets ) + { + c.d->m_presets = std::make_unique< Presets >(); + } +} + +Config::ApplyPresets::~ApplyPresets() +{ + m_c.m_unlocked = false; + + // Check that there's no **settings** (from the configuration map) + // that have not been consumed by apply() -- if they are there, + // that means the configuration map specifies things that the + // Config object does not expect. + bool haveWarned = false; + for ( const auto& k : m_map.keys() ) + { + if ( !m_c.d->m_presets->find( k ).isValid() ) + { + if ( !haveWarned ) + { + cWarning() << "Preset configuration contains unused keys"; + haveWarned = true; + } + cDebug() << Logger::SubEntry << "Unused key" << k; + } + } +} + +Config::ApplyPresets& +Config::ApplyPresets::apply( const char* fieldName ) +{ + const auto prop = m_c.property( fieldName ); + if ( !prop.isValid() ) + { + cWarning() << "Applying invalid property" << fieldName; + return *this; + } + + const QString key( fieldName ); + if ( key.isEmpty() ) + { + cWarning() << "Applying empty field"; + return *this; + } + + if ( m_c.d->m_presets->find( key ).isValid() ) + { + cWarning() << "Applying duplicate property" << fieldName; + return *this; + } + + if ( m_map.contains( key ) ) + { + // Key has an explicit setting + QVariantMap m = Calamares::getSubMap( m_map, key, m_bogus ); + QVariant value = m[ "value" ]; + bool editable = Calamares::getBool( m, "editable", true ); + + if ( value.isValid() ) + { + m_c.setProperty( fieldName, value ); + } + m_c.d->m_presets->append( PresetField { key, value, editable } ); + } + else + { + // There is no setting, but since we apply() this field, + // we do know about it; put in a preset so that looking + // it up won't complani. + m_c.d->m_presets->append( PresetField { key, QVariant(), true } ); + } + return *this; +} + +} // namespace ModuleSystem +} // namespace Calamares diff --git a/calamares/src/libcalamares/modulesystem/Config.h b/calamares/src/libcalamares/modulesystem/Config.h new file mode 100644 index 0000000..5facae3 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Config.h @@ -0,0 +1,147 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_MODULESYSTEM_CONFIG_H +#define CALAMARES_MODULESYSTEM_CONFIG_H + +#include "DllMacro.h" + +#include +#include +#include + +#include + +namespace Calamares +{ +namespace ModuleSystem +{ +/** @brief Base class for Config-objects + * + * This centralizes the things every Config-object should + * do and provides one source of preset-data. A Config-object + * for a module can **optionally** inherit from this class + * to get presets-support. + * + * TODO:3.3 This is not optional + * TODO:3.3 Put consistent i18n for Configurations in here too + */ +class DLLEXPORT Config : public QObject +{ + Q_OBJECT +public: + Config( QObject* parent = nullptr ); + ~Config() override; + + /** @brief Set the configuration from the config file + * + * Subclasses must implement this to load configuration data; + * that subclass **should** also call loadPresets() with the + * same map, to pick up the "presets" key consistently. + */ + virtual void setConfigurationMap( const QVariantMap& ) = 0; + +public Q_SLOTS: + /** @brief Checks if a @p fieldName is editable according to presets + * + * If the field is named as a preset, **and** the field is set + * to not-editable, returns @c false. Otherwise, return @c true. + * Calling this with an unknown field (one for which no presets + * are accepted) will print a warning and return @c true. + * + * @see CONFIG_PREVENT_EDITING + * + * Most setters will call isEditable() to check if the field should + * be editable. Do not count on the setter not being called: the + * UI might not have set the field to fixed / constant / not-editable + * and then you can have the setter called by changes in the UI. + * + * To prevent the UI from changing **and** to make sure that the UI + * reflects the unchanged value (rather than the changed value it + * sent to the Config object), use CONFIG_PREVENT_EDITING, like so: + * + * CONFIG_PREVENT_EDITING( type, "propertyName" ); + * + * The ; is necessary. is the type of the property; for instance + * QString. The name of the property is a (constant) string. The + * macro will return (out of the setter it is used in) if the field + * is not editable, and will send a notification event with the old + * value as soon as the event loop resumes. + */ + bool isEditable( const QString& fieldName ) const; + +protected: + friend class ApplyPresets; + /** @brief "Builder" class for presets + * + * Derived classes should instantiate this (with themselves, + * and the whole configuration map that is passed to + * setConfigurationMap()) and then call .apply() to apply + * the presets specified in the configuration to the **named** + * QObject properties. + */ + class ApplyPresets + { + public: + /** @brief Create a preset-applier for this config + * + * The @p configurationMap should be the one passed in to + * setConfigurationMap() . Presets are extracted from the + * standard key *presets* and can be applied to the configuration + * with apply() or operator<<. + */ + ApplyPresets( Config& c, const QVariantMap& configurationMap ); + ~ApplyPresets(); + + /** @brief Add a preset for the given @p fieldName + * + * This checks for preset-entries in the configuration map that was + * passed in to the constructor. + */ + ApplyPresets& apply( const char* fieldName ); + /** @brief Alternate way of writing apply() + */ + ApplyPresets& operator<<( const char* fieldName ) { return apply( fieldName ); } + + private: + Config& m_c; + bool m_bogus = true; + const QVariantMap m_map; + }; + +private: + class Private; + std::unique_ptr< Private > d; + bool m_unlocked = false; +}; +} // namespace ModuleSystem +} // namespace Calamares + +/// @see Config::isEditable() +// +// This needs to be a macro, because Q_ARG() is a macro that stringifies +// the type name. +#define CONFIG_PREVENT_EDITING( type, fieldName ) \ + do \ + { \ + if ( !isEditable( QStringLiteral( fieldName ) ) ) \ + { \ + auto prop = property( fieldName ); \ + const auto& metaobject = metaObject(); \ + auto metaprop = metaobject->property( metaobject->indexOfProperty( fieldName ) ); \ + if ( metaprop.hasNotifySignal() ) \ + { \ + metaprop.notifySignal().invoke( this, Qt::QueuedConnection, Q_ARG( type, prop.value< type >() ) ); \ + } \ + return; \ + } \ + } while ( 0 ) + + +#endif diff --git a/calamares/src/libcalamares/modulesystem/Descriptor.cpp b/calamares/src/libcalamares/modulesystem/Descriptor.cpp new file mode 100644 index 0000000..38c7bd0 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Descriptor.cpp @@ -0,0 +1,171 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "Descriptor.h" + +#include "utils/Logger.h" +#include "utils/Variant.h" + +namespace Calamares +{ +namespace ModuleSystem +{ + +const NamedEnumTable< Type >& +typeNames() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< Type > table{ + { QStringLiteral( "job" ), Type::Job }, + { QStringLiteral( "view" ), Type::View }, + { QStringLiteral( "viewmodule" ), Type::View }, + { QStringLiteral( "jobmodule" ), Type::Job } + }; + // *INDENT-ON* + // clang-format on + return table; +} + +const NamedEnumTable< Interface >& +interfaceNames() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< Interface > table { + { QStringLiteral("process"), Interface::Process }, + { QStringLiteral("qtplugin"), Interface::QtPlugin }, + { QStringLiteral("python"), Interface::Python }, + }; + // *INDENT-ON* + // clang-format on + return table; +} + +Descriptor::Descriptor() {} + +Descriptor +Descriptor::fromDescriptorData( const QVariantMap& moduleDesc, const QString& descriptorPath ) +{ + Descriptor d; + Logger::Once o; + + { + bool typeOk = false; + QString typeValue = moduleDesc.value( "type" ).toString(); + Type t = typeNames().find( typeValue, typeOk ); + if ( !typeOk ) + { + if ( o ) + { + cWarning() << o << "Descriptor file" << descriptorPath; + } + cWarning() << o << "Module descriptor contains invalid *type*" << typeValue; + } + + bool interfaceOk = false; + QString interfaceValue = moduleDesc.value( "interface" ).toString(); + Interface i = interfaceNames().find( interfaceValue, interfaceOk ); + if ( !interfaceOk ) + { + if ( o ) + { + cWarning() << o << "Descriptor file" << descriptorPath; + } + cWarning() << o << "Module descriptor contains invalid *interface*" << interfaceValue; + } + + d.m_name = moduleDesc.value( "name" ).toString(); + if ( typeOk && interfaceOk && !d.m_name.isEmpty() ) + { + d.m_type = t; + d.m_interface = i; + d.m_isValid = true; + } + } + if ( !d.m_isValid ) + { + return d; + } + + d.m_isEmergeny = Calamares::getBool( moduleDesc, "emergency", false ); + d.m_hasConfig = !Calamares::getBool( moduleDesc, "noconfig", false ); // Inverted logic during load + d.m_requiredModules = Calamares::getStringList( moduleDesc, "requiredModules" ); + d.m_weight = int( Calamares::getInteger( moduleDesc, "weight", -1 ) ); + + QStringList consumedKeys { "type", "interface", "name", "emergency", "noconfig", "requiredModules", "weight" }; + + switch ( d.interface() ) + { + case Interface::QtPlugin: + d.m_script = Calamares::getString( moduleDesc, "load" ); + consumedKeys << "load"; + break; + case Interface::Python: + d.m_script = Calamares::getString( moduleDesc, "script" ); + if ( d.m_script.isEmpty() ) + { + if ( o ) + { + cWarning() << o << "Descriptor file" << descriptorPath; + } + cWarning() << o << "Module descriptor contains no *script*" << d.name(); + d.m_isValid = false; + } + consumedKeys << "script"; + break; + case Interface::Process: + d.m_script = Calamares::getString( moduleDesc, "command" ); + d.m_processTimeout = int( Calamares::getInteger( moduleDesc, "timeout", 30 ) ); + d.m_processChroot = Calamares::getBool( moduleDesc, "chroot", false ); + if ( d.m_processTimeout < 0 ) + { + d.m_processTimeout = 0; + } + if ( d.m_script.isEmpty() ) + { + if ( o ) + { + cWarning() << o << "Descriptor file" << descriptorPath; + } + cWarning() << o << "Module descriptor contains no *script*" << d.name(); + d.m_isValid = false; + } + consumedKeys << "command" + << "timeout" + << "chroot"; + break; + } + + if ( !d.m_isValid ) + { + return d; + } + + QStringList superfluousKeys; + for ( auto kv = moduleDesc.keyBegin(); kv != moduleDesc.keyEnd(); ++kv ) + { + if ( !consumedKeys.contains( *kv ) ) + { + superfluousKeys << *kv; + } + } + if ( !superfluousKeys.isEmpty() ) + { + if ( o ) + { + cWarning() << o << "Descriptor file" << descriptorPath; + } + cWarning() << o << "Module descriptor contains extra keys:" << Logger::DebugList( superfluousKeys ); + d.m_isValid = false; + } + + return d; +} + +} // namespace ModuleSystem +} // namespace Calamares diff --git a/calamares/src/libcalamares/modulesystem/Descriptor.h b/calamares/src/libcalamares/modulesystem/Descriptor.h new file mode 100644 index 0000000..b3eefac --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Descriptor.h @@ -0,0 +1,148 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef MODULESYSTEM_DESCRIPTOR_H +#define MODULESYSTEM_DESCRIPTOR_H + +#include "DllMacro.h" +#include "utils/NamedEnum.h" + +#include + +namespace Calamares +{ +namespace ModuleSystem +{ +/** + * @brief The Type enum represents the intended functionality of the module + * Every module is either a job module or a view module. + * A job module is a single Calamares job. + * A view module has a UI (one or more view pages) and zero-to-many jobs. + */ +enum class Type +{ + Job, + View +}; +DLLEXPORT const NamedEnumTable< Type >& typeNames(); + +/** + * @brief The Interface enum represents the interface through which the module + * talks to Calamares. + * Not all Type-Interface associations are valid. + */ +enum class Interface +{ + QtPlugin, // Jobs or Views + Python, // Jobs only + Process, // Deprecated interface +}; +DLLEXPORT const NamedEnumTable< Interface >& interfaceNames(); + +/** + * @brief Description of a module (obtained from module.desc) + * + * Provides access to the fields of a descriptor, use type() to + * determine which specialized fields make sense for a given + * descriptor (e.g. a Python module has no shared-library path). + */ +class DLLEXPORT Descriptor +{ +public: + ///@brief an invalid, and empty, descriptor + Descriptor(); + + /** @brief Fills a descriptor from the loaded (YAML) data. + * + * The @p descriptorPath is used only for debug messages, the + * data is only read from @p moduleDesc. + * + */ + static Descriptor fromDescriptorData( const QVariantMap& moduleDesc, const QString& descriptorPath ); + + bool isValid() const { return m_isValid; } + + QString name() const { return m_name; } + Type type() const { return m_type; } + Interface interface() const { return m_interface; } + + bool isEmergency() const { return m_isEmergeny; } + bool hasConfig() const { return m_hasConfig; } // TODO: 3.5 rename to noConfig() to match descriptor key + int weight() const { return m_weight < 1 ? 1 : m_weight; } + bool explicitWeight() const { return m_weight > 0; } + + + /// @brief The directory where the module.desc lives + QString directory() const { return m_directory; } + void setDirectory( const QString& d ) { m_directory = d; } + + const QStringList& requiredModules() const { return m_requiredModules; } + + /** @section C++ Modules + * + * The C++ modules are the most general, and are loaded as + * a shared library after which a suitable factory creates + * objects from them. + */ + + /// @brief Short path to the shared-library; no extension. + QString load() const { return m_interface == Interface::QtPlugin ? m_script : QString(); } + + /** @section Process Job modules + * + * Process Jobs are somewhat deprecated in favor of shellprocess + * and contextualprocess jobs, since those handle multiple configuration + * much more gracefully. + * + * Process Jobs execute one command. + */ + /// @brief The command to execute; passed to the shell + QString command() const { return m_interface == Interface::Process ? m_script : QString(); } + /// @brief Timeout in seconds + int timeout() const { return m_processTimeout; } + /// @brief Run command in chroot? + bool chroot() const { return m_processChroot; } + + /** @section Python Job modules + * + * Python job modules have one specific script to load and run. + */ + QString script() const { return m_interface == Interface::Python ? m_script : QString(); } + +private: + QString m_name; + QString m_directory; + QStringList m_requiredModules; + int m_weight = -1; + Type m_type; + Interface m_interface; + bool m_isValid = false; + bool m_isEmergeny = false; + bool m_hasConfig = true; + + /** @brief The name of the thing to load + * + * - A C++ module loads a shared library (via key *load*), + * - A Python module loads a Python script (via key *script*), + * - A process runs a specific command (via key *command*) + * + * This name-of-the-thing is stored here, regardless of which + * interface is being used. + */ + QString m_script; + + int m_processTimeout = 30; + bool m_processChroot = false; +}; + +} // namespace ModuleSystem +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/modulesystem/InstanceKey.cpp b/calamares/src/libcalamares/modulesystem/InstanceKey.cpp new file mode 100644 index 0000000..948716f --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/InstanceKey.cpp @@ -0,0 +1,38 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#include "InstanceKey.h" + +namespace Calamares +{ +namespace ModuleSystem +{ + +InstanceKey +InstanceKey::fromString( const QString& s ) +{ + QStringList moduleEntrySplit = s.split( '@' ); + if ( moduleEntrySplit.length() < 1 || moduleEntrySplit.length() > 2 ) + { + return InstanceKey(); + } + // For length 1, first == last + return InstanceKey( moduleEntrySplit.first(), moduleEntrySplit.last() ); +} + + +QDebug& +operator<<( QDebug& s, const Calamares::ModuleSystem::InstanceKey& i ) +{ + return s << i.toString(); +} + +} // namespace ModuleSystem +} // namespace Calamares diff --git a/calamares/src/libcalamares/modulesystem/InstanceKey.h b/calamares/src/libcalamares/modulesystem/InstanceKey.h new file mode 100644 index 0000000..5153090 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/InstanceKey.h @@ -0,0 +1,117 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef MODULESYSTEM_INSTANCEKEY_H +#define MODULESYSTEM_INSTANCEKEY_H + +#include "DllMacro.h" + +#include +#include +#include + +#include +#include + +namespace Calamares +{ +namespace ModuleSystem +{ + +/** @brief A module instance's key (`module@id`) + * + * A module instance is identified by both the module's name + * (a Calamares module, e.g. `users`) and an instance id. + * Usually, the instance id is the same as the module name + * and the whole module instance key is `users@users`, but + * it is possible to use the same module more than once + * and then you distinguish those module instances by their + * secondary id (e.g. `users@one`). + * + * This is supported by the *instances* configuration entry + * in `settings.conf`. + */ +class DLLEXPORT InstanceKey +{ +public: + /// @brief Create an instance key from explicit module and id. + InstanceKey( const QString& module, const QString& id ) + : first( module ) + , second( id ) + { + if ( second.isEmpty() ) + { + second = first; + } + validate(); + } + + /// @brief Create unusual, invalid instance key + InstanceKey() = default; + + /// @brief A valid module has both name and id + bool isValid() const { return !first.isEmpty() && !second.isEmpty(); } + + /// @brief A custom module has a non-default id + bool isCustom() const { return first != second; } + + QString module() const { return first; } + QString id() const { return second; } + + /// @brief Create instance key from stringified version + static InstanceKey fromString( const QString& s ); + + QString toString() const + { + if ( isValid() ) + { + return first + '@' + second; + } + return QString(); + } + + friend bool operator==( const InstanceKey& lhs, const InstanceKey& rhs ) noexcept + { + return std::tie( lhs.first, lhs.second ) == std::tie( rhs.first, rhs.second ); + } + + friend bool operator<( const InstanceKey& lhs, const InstanceKey& rhs ) noexcept + { + return std::tie( lhs.first, lhs.second ) < std::tie( rhs.first, rhs.second ); + } + +private: + /** @brief Check validity and reset module and id if needed. */ + void validate() + { + if ( first.contains( '@' ) || second.contains( '@' ) ) + { + first = QString(); + second = QString(); + } + } + + QString first; + QString second; +}; + +using InstanceKeyList = QList< InstanceKey >; + +DLLEXPORT QDebug& operator<<( QDebug& s, const Calamares::ModuleSystem::InstanceKey& i ); +inline QDebug& +operator<<( QDebug&& s, const Calamares::ModuleSystem::InstanceKey& i ) +{ + return s << i; +} + +} // namespace ModuleSystem +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/modulesystem/Module.cpp b/calamares/src/libcalamares/modulesystem/Module.cpp new file mode 100644 index 0000000..24ab553 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Module.cpp @@ -0,0 +1,154 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Module.h" + +#include "CalamaresConfig.h" +#include "Settings.h" + +#include "utils/Dirs.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Yaml.h" + +#include +#include +#include +#include + +static const char EMERGENCY[] = "emergency"; + +namespace Calamares +{ + +Module::Module() + : m_loaded( false ) +{ +} + +Module::~Module() {} + +void +Module::initFrom( const Calamares::ModuleSystem::Descriptor& moduleDescriptor, const QString& id ) +{ + m_key = ModuleSystem::InstanceKey( moduleDescriptor.name(), id ); + if ( moduleDescriptor.isEmergency() ) + { + m_maybe_emergency = true; + } +} + +static QStringList +moduleConfigurationCandidates( bool assumeBuildDir, const QString& moduleName, const QString& configFileName ) +{ + QStringList paths; + + if ( Calamares::isAppDataDirOverridden() ) + { + paths << Calamares::appDataDir().absoluteFilePath( QString( "modules/%1" ).arg( configFileName ) ); + } + else + { + // If an absolute path is given, in debug mode, look for it + // first. The case contains('/'), below, will add the absolute + // path a second time, though. + if ( assumeBuildDir && configFileName.startsWith( '/' ) ) + { + paths << configFileName; + } + if ( assumeBuildDir ) + { + paths << QDir().absoluteFilePath( QString( "src/modules/%1/%2" ).arg( moduleName ).arg( configFileName ) ); + } + if ( assumeBuildDir && configFileName.contains( '/' ) ) + { + paths << QDir().absoluteFilePath( configFileName ); + } + + if ( Calamares::haveExtraDirs() ) + { + for ( auto s : Calamares::extraConfigDirs() ) + { + paths << ( s + QString( "modules/%1" ).arg( configFileName ) ); + } + } + + paths << QString( "/etc/calamares/modules/%1" ).arg( configFileName ); + paths << Calamares::appDataDir().absoluteFilePath( QString( "modules/%1" ).arg( configFileName ) ); + } + + return paths; +} + +void +Module::loadConfigurationFile( const QString& configFileName ) //throws YAML::Exception +{ + QStringList configCandidates + = moduleConfigurationCandidates( Settings::instance()->debugMode(), name(), configFileName ); + for ( const QString& path : configCandidates ) + { + QFile configFile( path ); + if ( configFile.exists() && configFile.open( QFile::ReadOnly | QFile::Text ) ) + { + QByteArray ba = configFile.readAll(); + + auto doc = ::YAML::Load( ba.constData() ); // Throws on error + if ( doc.IsNull() ) + { + cWarning() << "Found empty module configuration" << path; + // Special case: empty config files are valid, + // but aren't a map. + return; + } + if ( !doc.IsMap() ) + { + cWarning() << "Bad module configuration format" << path; + return; + } + + m_configurationMap = Calamares::YAML::mapToVariant( doc ); + m_emergency = m_maybe_emergency && m_configurationMap.contains( EMERGENCY ) + && m_configurationMap[ EMERGENCY ].toBool(); + return; + } + } + cWarning() << "No config file for" << name() << "found anywhere at" << Logger::DebugList( configCandidates ); +} + +QString +Module::typeString() const +{ + bool ok = false; + QString v = Calamares::ModuleSystem::typeNames().find( type(), ok ); + return ok ? v : QString(); +} + +QString +Module::interfaceString() const +{ + bool ok = false; + QString v = Calamares::ModuleSystem::interfaceNames().find( interface(), ok ); + return ok ? v : QString(); +} + +QVariantMap +Module::configurationMap() +{ + return m_configurationMap; +} + +RequirementsList +Module::checkRequirements() +{ + return RequirementsList(); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/modulesystem/Module.h b/calamares/src/libcalamares/modulesystem/Module.h new file mode 100644 index 0000000..88632cb --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Module.h @@ -0,0 +1,169 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef CALAMARES_MODULE_H +#define CALAMARES_MODULE_H + +#include "DllMacro.h" +#include "Job.h" + +#include "modulesystem/Descriptor.h" +#include "modulesystem/InstanceKey.h" +#include "modulesystem/Requirement.h" + +#include +#include + + +namespace Calamares +{ +class Module; +Module* moduleFromDescriptor( const ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ); + + +/** + * @brief The Module class is a common supertype for Calamares modules. + * It enforces a common interface for all the different types of modules, and it + * takes care of creating an object of the correct type starting from a module + * descriptor structure. + */ +class DLLEXPORT Module +{ +public: + using Type = ModuleSystem::Type; + using Interface = ModuleSystem::Interface; + + virtual ~Module(); + + /** + * @brief name returns the name of this module. + * @return a string with this module's name. + */ + QString name() const { return m_key.module(); } + + /** + * @brief instanceId returns the instance id of this module. + * @return a string with this module's instance id. + */ + QString instanceId() const { return m_key.id(); } + + /** + * @brief instanceKey returns the instance key of this module. + * @return a string with the instance key. + * A module instance's instance key is modulename\@instanceid. + * For instance, "partition\@partition" (default configuration) or + * "locale\@someconfig" (custom configuration) + */ + ModuleSystem::InstanceKey instanceKey() const { return m_key; } + + /** + * @brief location returns the full path of this module's directory. + * @return the path. + */ + QString location() const { return m_directory; } + + /** + * @brief Is this an emergency module? + * + * An emergency module is run even if an error occurs + * which would terminate Calamares earlier in the same + * *exec* block. Emergency modules in later exec blocks + * are not run (in the common case where there is only + * one exec block, this doesn't really matter). + */ + bool isEmergency() const { return m_emergency; } + + /** + * @brief isLoaded reports on the loaded status of a module. + * @return true if the module's loading phase has finished, otherwise false. + */ + bool isLoaded() const { return m_loaded; } + + /** + * @brief configurationMap returns the contents of the configuration file for + * this module instance. + * @return the instance's configuration, already parsed from YAML into a variant map. + */ + QVariantMap configurationMap(); + + /** + * @brief typeString returns a user-visible string for the module's type. + * @return the type string. + */ + QString typeString() const; + + /** + * @brief interface returns a user-visible string for the module's interface. + * @return the interface string. + */ + QString interfaceString() const; + + /** + * @brief loadSelf initialized the module. + * Subclasses must reimplement this depending on the module type and interface. + */ + virtual void loadSelf() = 0; + + /** + * @brief jobs returns any jobs exposed by this module. + * @return a list of jobs (can be empty). + */ + virtual JobList jobs() const = 0; + + /** + * @brief type returns the Type of this module object. + * @return the type enum value. + */ + virtual Type type() const = 0; + + /** + * @brief interface the Interface used by this module. + * @return the interface enum value. + */ + virtual Interface interface() const = 0; + + /** + * @brief Check the requirements of this module. + */ + virtual RequirementsList checkRequirements(); + +protected: + explicit Module(); + + /// @brief For subclasses to read their part of the descriptor + virtual void initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) = 0; + /// @brief Generic part of descriptor reading (and instance id) + void initFrom( const ModuleSystem::Descriptor& moduleDescriptor, const QString& id ); + + QVariantMap m_configurationMap; + + bool m_loaded = false; + bool m_emergency = false; // Based on module and local config + bool m_maybe_emergency = false; // Based on the module.desc + +private: + void loadConfigurationFile( const QString& configFileName ); //throws YAML::Exception + + QString m_directory; + ModuleSystem::InstanceKey m_key; + + friend Module* Calamares::moduleFromDescriptor( const ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ); +}; + +} // namespace Calamares + +#endif // CALAMARES_MODULE_H diff --git a/calamares/src/libcalamares/modulesystem/Preset.cpp b/calamares/src/libcalamares/modulesystem/Preset.cpp new file mode 100644 index 0000000..e980aae --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Preset.cpp @@ -0,0 +1,82 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Preset.h" + +#include "utils/Logger.h" +#include "utils/Variant.h" + +static void +loadPresets( Calamares::ModuleSystem::Presets& preset, + const QVariantMap& configurationMap, + std::function< bool( const QString& ) > pred ) +{ + cDebug() << "Creating presets" << preset.capacity(); + for ( auto it = configurationMap.cbegin(); it != configurationMap.cend(); ++it ) + { + if ( !it.key().isEmpty() && pred( it.key() ) ) + { + QVariantMap m = it.value().toMap(); + QString value = Calamares::getString( m, "value" ); + bool editable = Calamares::getBool( m, "editable", true ); + + preset.append( Calamares::ModuleSystem::PresetField { it.key(), value, editable } ); + + cDebug() << Logger::SubEntry << "Preset for" << it.key() << "applied editable?" << editable; + } + } +} + +namespace Calamares +{ +namespace ModuleSystem +{ +Presets::Presets( const QVariantMap& configurationMap ) +{ + reserve( configurationMap.count() ); + loadPresets( *this, configurationMap, []( const QString& ) { return true; } ); +} + +Presets::Presets( const QVariantMap& configurationMap, const QStringList& recognizedKeys ) +{ + reserve( recognizedKeys.size() ); + loadPresets( + *this, configurationMap, [ &recognizedKeys ]( const QString& s ) { return recognizedKeys.contains( s ); } ); +} + +bool +Presets::isEditable( const QString& fieldName ) const +{ + for ( const auto& p : *this ) + { + if ( p.fieldName == fieldName ) + { + return p.editable; + } + } + cWarning() << "Checking isEditable for unknown field" << fieldName; + return true; +} + +PresetField +Presets::find( const QString& fieldName ) const +{ + for ( const auto& p : *this ) + { + if ( p.fieldName == fieldName ) + { + return p; + } + } + + return PresetField(); +} + +} // namespace ModuleSystem +} // namespace Calamares diff --git a/calamares/src/libcalamares/modulesystem/Preset.h b/calamares/src/libcalamares/modulesystem/Preset.h new file mode 100644 index 0000000..b768a31 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Preset.h @@ -0,0 +1,91 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_MODULESYSTEM_PRESET_H +#define CALAMARES_MODULESYSTEM_PRESET_H + +#include +#include +#include + +namespace Calamares +{ +namespace ModuleSystem +{ +/** @brief The settings for a single field + * + * The settings apply to a single field; **often** this will + * correspond to a single value or property of a Config + * object, but there is no guarantee of a correspondence + * between names here and names in the code. + * + * The value is stored as a string; consumers (e.g. the UI) + * will need to translate the value to whatever is actually + * used (e.g. in the case of an integer field). + * + * By default, presets are still editable. Set that to @c false + * to make the field unchangeable (again, the UI is responsible + * for setting that up). + */ +struct PresetField +{ + QString fieldName; + QVariant value; + bool editable = true; + + bool isValid() const { return !fieldName.isEmpty(); } +}; + +/** @brief All the presets for one UI entity + * + * This is a collection of presets read from a module + * configuration file, one setting per field. + */ +class Presets : public QVector< PresetField > +{ +public: + /** @brief Reads preset entries from the map + * + * The map's keys are used as field name, and each value entry + * should specify an initial value and whether the entry is editable. + * Fields are editable by default. + */ + explicit Presets( const QVariantMap& configurationMap ); + /** @brief Reads preset entries from the @p configurationMap + * + * As above, but only field names that occur in @p recognizedKeys + * are kept; others are discarded. + */ + Presets( const QVariantMap& configurationMap, const QStringList& recognizedKeys ); + + /** @brief Creates an empty presets map + * + * This constructor is primarily intended for use by the ApplyPresets + * helper class, which will reserve suitable space and load + * presets on-demand. + */ + Presets() = default; + + /** @brief Is the given @p fieldName editable? + * + * Fields are editable by default, so if there is no explicit setting, + * returns @c true. + */ + bool isEditable( const QString& fieldName ) const; + + /** @brief Finds the settings for a field @p fieldName + * + * If there is no such field, returns an invalid PresetField. + */ + PresetField find( const QString& fieldName ) const; +}; +} // namespace ModuleSystem +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/modulesystem/Requirement.h b/calamares/src/libcalamares/modulesystem/Requirement.h new file mode 100644 index 0000000..dc9fe1d --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Requirement.h @@ -0,0 +1,61 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_REQUIREMENT_H +#define CALAMARES_REQUIREMENT_H + +#include "DllMacro.h" + +#include +#include +#include + +#include + +namespace Calamares +{ + +/** + * An indication of a requirement, which is checked in preparation + * for system installation. An entry has a name and some explanation functions + * (functions, because they need to respond to translations). + * + * A requirement can be *satisfied* or not. + * A requirement can be optional (i.e. a "good to have") or mandatory. + * + * Requirements which are not satisfied, and also mandatory, will prevent the + * installation from proceeding. + */ +struct RequirementEntry +{ + using TextFunction = std::function< QString() >; + + /// @brief name of this requirement; not shown to user and used as ID + QString name; + + /// @brief Detailed description of this requirement, for use in user-visible lists + TextFunction enumerationText; + + /// @brief User-visible string to show that the requirement is not met, short form + TextFunction negatedText; + + bool satisfied; + bool mandatory; + + /// @brief Convenience to check if this entry should be shown in details dialog + bool hasDetails() const { return !enumerationText().isEmpty(); } +}; + +using RequirementsList = QList< RequirementEntry >; + +} // namespace Calamares + +Q_DECLARE_METATYPE( Calamares::RequirementEntry ) + +#endif diff --git a/calamares/src/libcalamares/modulesystem/RequirementsChecker.cpp b/calamares/src/libcalamares/modulesystem/RequirementsChecker.cpp new file mode 100644 index 0000000..25930b9 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/RequirementsChecker.cpp @@ -0,0 +1,134 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "RequirementsChecker.h" + +#include "compat/Mutex.h" +#include "compat/Size.h" +#include "modulesystem/Module.h" +#include "modulesystem/Requirement.h" +#include "modulesystem/RequirementsModel.h" +#include "utils/Logger.h" + +#include +#include +#include +#include + +#include + +namespace Calamares +{ + +RequirementsChecker::RequirementsChecker( QVector< Module* > modules, RequirementsModel* model, QObject* parent ) + : QObject( parent ) + , m_modules( std::move( modules ) ) + , m_model( model ) + , m_progressTimer( nullptr ) + , m_progressTimeouts( 0 ) +{ + m_watchers.reserve( m_modules.count() ); + connect( this, &RequirementsChecker::requirementsProgress, model, &RequirementsModel::setProgressMessage ); +} + +RequirementsChecker::~RequirementsChecker() {} + +void +RequirementsChecker::run() +{ + m_progressTimer = new QTimer( this ); + connect( m_progressTimer, &QTimer::timeout, this, &RequirementsChecker::reportProgress ); + m_progressTimer->start( std::chrono::milliseconds( 1200 ) ); + + for ( const auto& module : m_modules ) + { + Watcher* watcher = new Watcher( this ); +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + watcher->setFuture( QtConcurrent::run( this, &RequirementsChecker::addCheckedRequirements, module ) ); +#else + watcher->setFuture( QtConcurrent::run( &RequirementsChecker::addCheckedRequirements, this, module ) ); +#endif + watcher->setObjectName( module->name() ); + m_watchers.append( watcher ); + connect( watcher, &Watcher::finished, this, &RequirementsChecker::finished ); + } + + QTimer::singleShot( 0, this, &RequirementsChecker::finished ); +} + +void +RequirementsChecker::finished() +{ + static QMutex finishedMutex; + Calamares::MutexLocker lock( &finishedMutex ); + + if ( m_progressTimer + && std::all_of( + m_watchers.cbegin(), m_watchers.cend(), []( const Watcher* w ) { return w && w->isFinished(); } ) ) + { + cDebug() << "All requirements have been checked."; + if ( m_progressTimer ) + { + m_progressTimer->stop(); + delete m_progressTimer; + m_progressTimer = nullptr; + } + + m_model->describe(); + m_model->reCheckList(); + QTimer::singleShot( 0, this, &RequirementsChecker::done ); + } +} + +void +RequirementsChecker::addCheckedRequirements( Module* m ) +{ + RequirementsList l = m->checkRequirements(); + if ( l.count() > 0 ) + { + cDebug() << "Got" << l.count() << "requirement results from" << m->name(); + m_model->addRequirementsList( l ); + } + + Q_EMIT requirementsProgress( tr( "Requirements checking for module '%1' is complete.", "@info" ).arg( m->name() ) ); +} + +void +RequirementsChecker::reportProgress() +{ + m_progressTimeouts++; + + QStringList remainingNames; + auto remaining = std::count_if( m_watchers.cbegin(), + m_watchers.cend(), + [ & ]( const Watcher* w ) + { + if ( w && !w->isFinished() ) + { + remainingNames << w->objectName(); + return true; + } + return false; + } ); + if ( remaining > 0 ) + { + cDebug() << "Remaining modules:" << remaining << Logger::DebugList( remainingNames ); + unsigned int posInterval = ( m_progressTimer->interval() < 0 ) ? 1000 : uint( m_progressTimer->interval() ); + QString waiting = tr( "Waiting for %n module(s)…", "@status", static_cast(remaining) ); + QString elapsed = tr( "(%n second(s))", "@status", m_progressTimeouts * posInterval / 999 ); + Q_EMIT requirementsProgress( waiting + QString( " " ) + elapsed ); + } + else + { + Q_EMIT requirementsProgress( tr( "System-requirements checking is complete.", "@info" ) ); + } +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/modulesystem/RequirementsChecker.h b/calamares/src/libcalamares/modulesystem/RequirementsChecker.h new file mode 100644 index 0000000..befa32b --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/RequirementsChecker.h @@ -0,0 +1,73 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef CALAMARES_REQUIREMENTSCHECKER_H +#define CALAMARES_REQUIREMENTSCHECKER_H + +#include "DllMacro.h" +#include "modulesystem/Requirement.h" + +#include +#include +#include +#include + +namespace Calamares +{ + +class Module; +class RequirementsModel; + +/** @brief A manager-class that checks all the module requirements + * + * Asynchronously checks the requirements for each module, and + * emits progress signals as appropriate. + */ +class DLLEXPORT RequirementsChecker : public QObject +{ + Q_OBJECT + +public: + RequirementsChecker( QVector< Module* > modules, RequirementsModel* model, QObject* parent = nullptr ); + ~RequirementsChecker() override; + +public Q_SLOTS: + /// @brief Start checking all the requirements + void run(); + + /// @brief Called when requirements are reported by a module + void addCheckedRequirements( Module* ); + + /// @brief Called when all requirements have been checked + void finished(); + + /// @brief Called periodically while requirements are being checked + void reportProgress(); + +signals: + /// @brief Human-readable progress message + void requirementsProgress( const QString& ); + /// @brief Emitted after requirementsComplete + void done(); + +private: + QVector< Module* > m_modules; + + using Watcher = QFutureWatcher< void >; + QVector< Watcher* > m_watchers; + + RequirementsModel* m_model; + + QTimer* m_progressTimer; + unsigned m_progressTimeouts; +}; + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/modulesystem/RequirementsModel.cpp b/calamares/src/libcalamares/modulesystem/RequirementsModel.cpp new file mode 100644 index 0000000..4b44d62 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/RequirementsModel.cpp @@ -0,0 +1,129 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "RequirementsModel.h" + +#include "compat/Mutex.h" +#include "utils/Logger.h" + +namespace Calamares +{ + +void +RequirementsModel::clear() +{ + Calamares::MutexLocker l( &m_addLock ); + beginResetModel(); + m_requirements.clear(); + endResetModel(); + reCheckList(); +} + +void +RequirementsModel::addRequirementsList( const Calamares::RequirementsList& requirements ) +{ + Calamares::MutexLocker l( &m_addLock ); + + beginResetModel(); + for ( const auto& r : requirements ) + { + auto it = std::find_if( m_requirements.begin(), + m_requirements.end(), + [ &r ]( const Calamares::RequirementEntry& re ) { return r.name == re.name; } ); + if ( it != m_requirements.end() ) + { + *it = r; + } + else + { + m_requirements.append( r ); + } + } + endResetModel(); + reCheckList(); +} + +void +RequirementsModel::reCheckList() +{ + auto isUnSatisfied = []( const Calamares::RequirementEntry& e ) { return !e.satisfied; }; + auto isMandatoryAndUnSatisfied = []( const Calamares::RequirementEntry& e ) { return e.mandatory && !e.satisfied; }; + + m_satisfiedRequirements = std::none_of( m_requirements.begin(), m_requirements.end(), isUnSatisfied ); + m_satisfiedMandatory = std::none_of( m_requirements.begin(), m_requirements.end(), isMandatoryAndUnSatisfied ); + + Q_EMIT satisfiedRequirementsChanged( m_satisfiedRequirements ); + Q_EMIT satisfiedMandatoryChanged( m_satisfiedMandatory ); +} + +int +RequirementsModel::rowCount( const QModelIndex& ) const +{ + return static_cast< int >( m_requirements.count() ); // TODO 3.4 use qsizetype +} + +QVariant +RequirementsModel::data( const QModelIndex& index, int role ) const +{ + const auto requirement = m_requirements.at( index.row() ); + + switch ( role ) + { + case Roles::Name: + return requirement.name; + case Roles::Details: + return requirement.enumerationText(); + case Roles::NegatedText: + return requirement.negatedText(); + case Roles::Satisfied: + return requirement.satisfied; + case Roles::Mandatory: + return requirement.mandatory; + case Roles::HasDetails: + return requirement.hasDetails(); + default: + return QVariant(); + } +} + +QHash< int, QByteArray > +RequirementsModel::roleNames() const +{ + static QHash< int, QByteArray > roles; + roles[ Roles::Name ] = "name"; + roles[ Roles::Details ] = "details"; + roles[ Roles::NegatedText ] = "negatedText"; + roles[ Roles::Satisfied ] = "satisfied"; + roles[ Roles::Mandatory ] = "mandatory"; + roles[ Roles::HasDetails ] = "hasDetails"; + return roles; +} + +void +RequirementsModel::describe() const +{ + cDebug() << "Requirements model has" << m_requirements.count() << "items"; + int count = 0; + for ( const auto& r : m_requirements ) + { + cDebug() << Logger::SubEntry << "requirement" << count << r.name << "satisfied?" << r.satisfied << "mandatory?" + << r.mandatory; + ++count; + } +} + +void +RequirementsModel::setProgressMessage( const QString& m ) +{ + m_progressMessage = m; + Q_EMIT progressMessageChanged( m_progressMessage ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/modulesystem/RequirementsModel.h b/calamares/src/libcalamares/modulesystem/RequirementsModel.h new file mode 100644 index 0000000..e6cd068 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/RequirementsModel.h @@ -0,0 +1,100 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef CALAMARES_REQUIREMENTSMODEL_H +#define CALAMARES_REQUIREMENTSMODEL_H + +#include "Requirement.h" + +#include "DllMacro.h" + +#include +#include + +namespace Calamares +{ +class RequirementsChecker; + +/** @brief System requirements from each module and their checked-status + * + * A Calamares module can have system requirements (e.g. check for + * internet, or amount of RAM, or an available disk) which can + * be stated and checked. + * + * This model collects those requirements, can run the checks, and + * reports on the overall status of those checks. + */ +class DLLEXPORT RequirementsModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY( bool satisfiedRequirements READ satisfiedRequirements NOTIFY satisfiedRequirementsChanged FINAL ) + Q_PROPERTY( bool satisfiedMandatory READ satisfiedMandatory NOTIFY satisfiedMandatoryChanged FINAL ) + Q_PROPERTY( QString progressMessage READ progressMessage NOTIFY progressMessageChanged FINAL ) + +public: + using QAbstractListModel::QAbstractListModel; + + enum Roles : short + { + NegatedText = Qt::DisplayRole, + Details = Qt::ToolTipRole, + Name = Qt::UserRole, + Satisfied, + Mandatory, + HasDetails + }; + // No Q_ENUM because these are exposed through roleNames() + + ///@brief Are all the requirements satisfied? + bool satisfiedRequirements() const { return m_satisfiedRequirements; } + ///@brief Are all the **mandatory** requirements satisfied? + bool satisfiedMandatory() const { return m_satisfiedMandatory; } + ///@brief Message (from an ongoing check) about progress + QString progressMessage() const { return m_progressMessage; } + + + QVariant data( const QModelIndex& index, int role ) const override; + int rowCount( const QModelIndex& ) const override; // TODO 3.4 use qsizetype + int count() const { return static_cast< int >( m_requirements.count() ); } // TODO 3.4 use qsizetype + + ///@brief Debugging tool, describe the checking-state + void describe() const; + + ///@brief Update progress message (called by the checker) + void setProgressMessage( const QString& m ); + + ///@brief Append some requirements; resets the model + void addRequirementsList( const Calamares::RequirementsList& requirements ); + + ///@brief Check the whole list, emit signals satisfied...() + void reCheckList(); + +signals: + void satisfiedRequirementsChanged( bool value ); + void satisfiedMandatoryChanged( bool value ); + void progressMessageChanged( QString message ); + +protected: + QHash< int, QByteArray > roleNames() const override; + + ///@brief Clears the requirements; resets the model + void clear(); + +private: + QString m_progressMessage; + QMutex m_addLock; + RequirementsList m_requirements; + bool m_satisfiedRequirements = false; + bool m_satisfiedMandatory = false; +}; + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/modulesystem/Tests.cpp b/calamares/src/libcalamares/modulesystem/Tests.cpp new file mode 100644 index 0000000..13a9c86 --- /dev/null +++ b/calamares/src/libcalamares/modulesystem/Tests.cpp @@ -0,0 +1,179 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "modulesystem/Descriptor.h" +#include "modulesystem/InstanceKey.h" + +#include + +using Calamares::ModuleSystem::InstanceKey; + +class ModuleSystemTests : public QObject +{ + Q_OBJECT +public: + ModuleSystemTests() {} + ~ModuleSystemTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testEmptyInstanceKey(); + void testCustomInstanceKey(); + void testFromStringInstanceKey(); + + void testBadSimpleCases(); + void testBadFromStringCases(); + + void testBasicDescriptor(); +}; + +void +ModuleSystemTests::initTestCase() +{ +} + +void +assert_is_invalid( const InstanceKey& k ) +{ + QVERIFY( !k.isValid() ); + QVERIFY( !k.isCustom() ); + QVERIFY( k.module().isEmpty() ); + QVERIFY( k.id().isEmpty() ); + if ( k.toString().isEmpty() ) + { + QVERIFY( k.toString().isEmpty() ); + } + else + { + QCOMPARE( k.toString(), QString() ); + } +} + +void +ModuleSystemTests::testEmptyInstanceKey() +{ + InstanceKey k0; + assert_is_invalid( k0 ); +} + +void +ModuleSystemTests::testCustomInstanceKey() +{ + InstanceKey k0( "derp", "derp" ); + QVERIFY( k0.isValid() ); + QVERIFY( !k0.isCustom() ); + QCOMPARE( k0.module(), QStringLiteral( "derp" ) ); + QCOMPARE( k0.id(), QStringLiteral( "derp" ) ); + QCOMPARE( k0.toString(), QStringLiteral( "derp@derp" ) ); + + InstanceKey k1( "derp", "horse" ); + QVERIFY( k1.isValid() ); + QVERIFY( k1.isCustom() ); + QCOMPARE( k1.module(), QStringLiteral( "derp" ) ); + QCOMPARE( k1.id(), QStringLiteral( "horse" ) ); + QCOMPARE( k1.toString(), QStringLiteral( "derp@horse" ) ); + + InstanceKey k4( "derp", QString() ); + QVERIFY( k4.isValid() ); + QVERIFY( !k4.isCustom() ); + QCOMPARE( k4.module(), QStringLiteral( "derp" ) ); + QCOMPARE( k4.id(), QStringLiteral( "derp" ) ); + QCOMPARE( k4.toString(), QStringLiteral( "derp@derp" ) ); +} + +void +ModuleSystemTests::testFromStringInstanceKey() +{ + InstanceKey k0 = InstanceKey::fromString( "derp@derp" ); + QVERIFY( k0.isValid() ); + QVERIFY( !k0.isCustom() ); + QCOMPARE( k0.module(), QStringLiteral( "derp" ) ); + QCOMPARE( k0.id(), QStringLiteral( "derp" ) ); + + InstanceKey k1 = InstanceKey::fromString( "derp@horse" ); + QVERIFY( k1.isValid() ); + QVERIFY( k1.isCustom() ); + QCOMPARE( k1.module(), QStringLiteral( "derp" ) ); + QCOMPARE( k1.id(), QStringLiteral( "horse" ) ); + + InstanceKey k2 = InstanceKey::fromString( "derp" ); + QVERIFY( k2.isValid() ); + QVERIFY( !k2.isCustom() ); + QCOMPARE( k2.module(), QStringLiteral( "derp" ) ); + QCOMPARE( k2.id(), QStringLiteral( "derp" ) ); +} + +/// @brief These are expected to fail since they show bugs in the code +void +ModuleSystemTests::testBadSimpleCases() +{ + InstanceKey k4( "derp", "derp@derp" ); + assert_is_invalid( k4 ); +} + +void +ModuleSystemTests::testBadFromStringCases() +{ + InstanceKey k0 = InstanceKey::fromString( QString() ); + assert_is_invalid( k0 ); + + k0 = InstanceKey::fromString( "derp@derp@derp" ); + assert_is_invalid( k0 ); +} + +void +ModuleSystemTests::testBasicDescriptor() +{ + const QString path = QStringLiteral( "/bogus.desc" ); + { + QVariantMap m; + auto d = Calamares::ModuleSystem::Descriptor::fromDescriptorData( m, path ); + + QVERIFY( !d.isValid() ); + QVERIFY( d.name().isEmpty() ); + } + { + QVariantMap m; + m.insert( "name", QVariant() ); + auto d = Calamares::ModuleSystem::Descriptor::fromDescriptorData( m, path ); + + QVERIFY( !d.isValid() ); + QVERIFY( d.name().isEmpty() ); + } + { + QVariantMap m; + m.insert( "name", 17 ); + auto d = Calamares::ModuleSystem::Descriptor::fromDescriptorData( m, path ); + + QVERIFY( !d.isValid() ); + QVERIFY( !d.name().isEmpty() ); + QCOMPARE( d.name(), QStringLiteral( "17" ) ); // Strange but true + } + { + QVariantMap m; + m.insert( "name", "welcome" ); + m.insert( "type", "viewmodule" ); + m.insert( "interface", "qtplugin" ); + auto d = Calamares::ModuleSystem::Descriptor::fromDescriptorData( m, path ); + + // QVERIFY( !d.isValid() ); + QCOMPARE( d.name(), QStringLiteral( "welcome" ) ); + QCOMPARE( d.type(), Calamares::ModuleSystem::Type::View ); + QCOMPARE( d.interface(), Calamares::ModuleSystem::Interface::QtPlugin ); + } +} + + +QTEST_GUILESS_MAIN( ModuleSystemTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/libcalamares/network/Manager.cpp b/calamares/src/libcalamares/network/Manager.cpp new file mode 100644 index 0000000..5bb480e --- /dev/null +++ b/calamares/src/libcalamares/network/Manager.cpp @@ -0,0 +1,438 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Manager.h" + +#include "compat/Mutex.h" +#include "utils/Logger.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +/** @brief Does a request asynchronously, returns the (pending) reply + * + * The extra options for the request are taken from @p options, + * including the timeout setting. A timeout will cause the reply + * to abort. The reply is **not** scheduled for deletion. + * + * On failure, returns nullptr (e.g. bad URL, timeout). + */ +static QNetworkReply* +asynchronousRun( QNetworkAccessManager* nam, const QUrl& url, const Calamares::Network::RequestOptions& options ) +{ + QNetworkRequest request = QNetworkRequest( url ); + options.applyToRequest( &request ); + + QNetworkReply* reply = nam->get( request ); + QTimer* timer = nullptr; + + // Bail out early if the request is bad + if ( reply->error() ) + { + cWarning() << "Early reply error" << reply->error() << reply->errorString(); + reply->deleteLater(); + return nullptr; + } + + if ( options.hasTimeout() ) + { + timer = new QTimer( reply ); + timer->setSingleShot( true ); + QObject::connect( timer, &QTimer::timeout, reply, &QNetworkReply::abort ); + timer->start( options.timeout() ); + } + + return reply; +} + +/** @brief Does a request synchronously, returns the request itself + * + * The extra options for the request are taken from @p options, + * including the timeout setting. + * + * On failure, returns nullptr (e.g. bad URL, timeout). The request + * is marked for later automatic deletion, so don't store the pointer. + */ +static QPair< Calamares::Network::RequestStatus, QNetworkReply* > +synchronousRun( QNetworkAccessManager* nam, const QUrl& url, const Calamares::Network::RequestOptions& options ) +{ + auto* reply = asynchronousRun( nam, url, options ); + if ( !reply ) + { + cDebug() << "Could not create request for" << url; + return qMakePair( Calamares::Network::RequestStatus::Failed, nullptr ); + } + + QEventLoop loop; + QObject::connect( reply, &QNetworkReply::finished, &loop, &QEventLoop::quit ); + loop.exec(); + reply->deleteLater(); + if ( reply->isRunning() ) + { + cDebug() << "Timeout on request for" << url; + return qMakePair( Calamares::Network::RequestStatus::Timeout, nullptr ); + } + else if ( reply->error() != QNetworkReply::NoError ) + { + cDebug() << "HTTP error" << reply->error() << "on request for" << url; + return qMakePair( Calamares::Network::RequestStatus::HttpError, nullptr ); + } + else + { + return qMakePair( Calamares::Network::RequestStatus::Ok, reply ); + } +} + +static Calamares::Network::RequestStatus +synchronousPing( QNetworkAccessManager* nam, const QUrl& url, const Calamares::Network::RequestOptions& options ) +{ + if ( !url.isValid() ) + { + return Calamares::Network::RequestStatus::Failed; + } + + auto reply = synchronousRun( nam, url, options ); + if ( reply.first ) + { + return reply.second->bytesAvailable() ? Calamares::Network::RequestStatus::Ok + : Calamares::Network::RequestStatus::Empty; + } + else + { + return reply.first; + } +} + +/** @brief Mutex protecting the singleton Private instance */ +static QMutex* +namMutex() +{ + static QMutex namMutex; + return &namMutex; +} + +namespace Calamares +{ +namespace Network +{ +void +RequestOptions::applyToRequest( QNetworkRequest* request ) const +{ + + if ( m_flags & Flag::FollowRedirect ) + { + // Follows all redirects except unsafe ones (https to http). + request->setAttribute( QNetworkRequest::RedirectPolicyAttribute, true ); + } + + if ( m_flags & Flag::FakeUserAgent ) + { + // Not everybody likes the default User Agent used by this class (looking at you, + // sourceforge.net), so let's set a more descriptive one. + request->setRawHeader( "User-Agent", "Mozilla/5.0 (compatible; Calamares)" ); + } +} + +/** + * @class + * + * The Private class is intended as a @b singleton and it + * holds a QNetworkManager per thread (for network access + * from that thread), and also caches have-internet data + * to share across Manager instances. + */ +class Manager::Private : public QObject +{ + Q_OBJECT +private: + std::unique_ptr< QNetworkAccessManager > m_nam; + + using ThreadNam = QPair< QThread*, QNetworkAccessManager* >; + QVector< ThreadNam > m_perThreadNams; + + Private(); + + QVector< QUrl > m_hasInternetUrls; + bool m_hasInternet = false; + int m_lastCheckedUrlIndex = -1; + +public slots: + void cleanupNam(); + +public: + bool hasInternet() const; + bool checkHasInternet(); + void setCheckHasInternetUrl( const QUrl& url ); + void setCheckHasInternetUrl( const QVector< QUrl >& urls ); + void addCheckHasInternetUrl( const QUrl& url ); + QVector< QUrl > getCheckInternetUrls() const; + + /** @brief Returns the NAM for this thread */ + QNetworkAccessManager* nam(); + + static Private* instance(); +}; + +Manager::Private::Private() + : m_nam( std::make_unique< QNetworkAccessManager >() ) + , m_hasInternet( false ) +{ + m_perThreadNams.reserve( 20 ); + m_perThreadNams.append( qMakePair( QThread::currentThread(), m_nam.get() ) ); +} + +bool +Manager::Private::hasInternet() const +{ + Calamares::MutexLocker lock( namMutex() ); + return m_hasInternet; +} + +bool +Manager::Private::checkHasInternet() +{ + // Locks separately + auto* threadNAM = nam(); + + Calamares::MutexLocker lock( namMutex() ); + + if ( m_hasInternetUrls.empty() ) + { + return false; + } + // It's possible that access was switched off (see below, if the check + // fails) so we want to turn it back on first. Otherwise all the + // checks will fail **anyway**, defeating the point of the checks. + if ( m_lastCheckedUrlIndex < 0 ) + { + m_lastCheckedUrlIndex = 0; + } + int attempts = 0; + do + { + // Start by pinging the same one as last time + m_hasInternet = ::synchronousPing( threadNAM, m_hasInternetUrls.at( m_lastCheckedUrlIndex ), RequestOptions() ); + // if it's not responding, **then** move on to the next one, + // and wrap around if needed + if ( !m_hasInternet ) + { + if ( ++( m_lastCheckedUrlIndex ) >= m_hasInternetUrls.size() ) + { + m_lastCheckedUrlIndex = 0; + } + } + // keep track of how often we've tried, because there's no point in + // going around more than once. + attempts++; + } while ( !m_hasInternet && ( attempts < m_hasInternetUrls.size() ) ); + + return m_hasInternet; +} + +void +Manager::Private::setCheckHasInternetUrl( const QUrl& url ) +{ + Calamares::MutexLocker lock( namMutex() ); + + m_lastCheckedUrlIndex = -1; + m_hasInternetUrls.clear(); + if ( url.isValid() ) + { + m_hasInternetUrls.append( url ); + } +} + +void +Manager::Private::setCheckHasInternetUrl( const QVector< QUrl >& urls ) +{ + Calamares::MutexLocker lock( namMutex() ); + + m_lastCheckedUrlIndex = -1; + m_hasInternetUrls = urls; + auto it = std::remove_if( + m_hasInternetUrls.begin(), m_hasInternetUrls.end(), []( const QUrl& u ) { return !u.isValid(); } ); + if ( it != m_hasInternetUrls.end() ) + { + m_hasInternetUrls.erase( it, m_hasInternetUrls.end() ); + } +} + +void +Manager::Private::addCheckHasInternetUrl( const QUrl& url ) +{ + if ( url.isValid() ) + { + Calamares::MutexLocker lock( namMutex() ); + m_hasInternetUrls.append( url ); + } +} + +QVector< QUrl > +Manager::Private::getCheckInternetUrls() const +{ + Calamares::MutexLocker lock( namMutex() ); + return m_hasInternetUrls; +} + +QNetworkAccessManager* +Manager::Private::nam() +{ + Calamares::MutexLocker lock( namMutex() ); + + auto* thread = QThread::currentThread(); + for ( const auto& n : m_perThreadNams ) + { + if ( n.first == thread ) + { + return n.second; + } + } + + // Need a new NAM for this thread + QNetworkAccessManager* nam = new QNetworkAccessManager(); + m_perThreadNams.append( qMakePair( thread, nam ) ); + QObject::connect( thread, &QThread::finished, this, &Manager::Private::cleanupNam ); + + return nam; +} + +void +Manager::Private::cleanupNam() +{ + Calamares::MutexLocker lock( namMutex() ); + + auto* thread = QThread::currentThread(); + bool cleanupFound = false; + int cleanupIndex = 0; + for ( const auto& n : m_perThreadNams ) + { + if ( n.first == thread ) + { + cleanupFound = true; + delete n.second; + break; + } + ++cleanupIndex; + } + if ( cleanupFound ) + { + m_perThreadNams.remove( cleanupIndex ); + } +} + +Manager::Private* +Manager::Private::instance() +{ + static auto* p = new Manager::Private; + return p; +} + +Manager::Manager() {} + +Manager::~Manager() {} + +bool +Manager::hasInternet() +{ + return Private::instance()->hasInternet(); +} + +bool +Manager::checkHasInternet() +{ + const auto v = Private::instance()->checkHasInternet(); + emit hasInternetChanged( v ); + return v; +} + +void +Manager::setCheckHasInternetUrl( const QUrl& url ) +{ + Private::instance()->setCheckHasInternetUrl( url ); +} + +void +Manager::setCheckHasInternetUrl( const QVector< QUrl >& urls ) +{ + Private::instance()->setCheckHasInternetUrl( urls ); +} + +void +Manager::addCheckHasInternetUrl( const QUrl& url ) +{ + Private::instance()->addCheckHasInternetUrl( url ); +} + +QVector< QUrl > +Manager::getCheckInternetUrls() +{ + return Private::instance()->getCheckInternetUrls(); +} + +RequestStatus +Manager::synchronousPing( const QUrl& url, const RequestOptions& options ) +{ + return ::synchronousPing( Private::instance()->nam(), url, options ); +} + +QByteArray +Manager::synchronousGet( const QUrl& url, const RequestOptions& options ) +{ + if ( !url.isValid() ) + { + return QByteArray(); + } + + auto reply = synchronousRun( Private::instance()->nam(), url, options ); + return reply.first ? reply.second->readAll() : QByteArray(); +} + +QNetworkReply* +Manager::asynchronousGet( const QUrl& url, const Calamares::Network::RequestOptions& options ) +{ + return asynchronousRun( Private::instance()->nam(), url, options ); +} + +QDebug& +operator<<( QDebug& s, const Calamares::Network::RequestStatus& e ) +{ + s << int( e.status ) << bool( e ); + switch ( e.status ) + { + case RequestStatus::Ok: + break; + case RequestStatus::Timeout: + s << "Timeout"; + break; + case RequestStatus::Failed: + s << "Failed"; + break; + case RequestStatus::HttpError: + s << "HTTP"; + break; + case RequestStatus::Empty: + s << "Empty"; + break; + } + return s; +} + +} // namespace Network +} // namespace Calamares + +#include "utils/moc-warnings.h" + +#include "Manager.moc" diff --git a/calamares/src/libcalamares/network/Manager.h b/calamares/src/libcalamares/network/Manager.h new file mode 100644 index 0000000..7520388 --- /dev/null +++ b/calamares/src/libcalamares/network/Manager.h @@ -0,0 +1,166 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARES_NETWORK_MANAGER_H +#define LIBCALAMARES_NETWORK_MANAGER_H + +#include "DllMacro.h" + +#include +#include +#include +#include +#include + +#include +#include + +class QNetworkReply; +class QNetworkRequest; + +namespace Calamares +{ +namespace Network +{ +class DLLEXPORT RequestOptions +{ +public: + using milliseconds = std::chrono::milliseconds; + + enum Flag + { + FollowRedirect = 0x1, + FakeUserAgent = 0x100 + }; + Q_DECLARE_FLAGS( Flags, Flag ) + + RequestOptions() + : m_flags( Flags() ) + , m_timeout( -1 ) + { + } + + RequestOptions( Flags f, milliseconds timeout = milliseconds( -1 ) ) + : m_flags( f ) + , m_timeout( timeout ) + { + } + + void applyToRequest( QNetworkRequest* ) const; + + bool hasTimeout() const { return m_timeout > milliseconds( 0 ); } + auto timeout() const { return m_timeout; } + +private: + Flags m_flags; + milliseconds m_timeout; +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS( RequestOptions::Flags ); + +struct RequestStatus +{ + enum State + { + Ok, + Timeout, // Timeout exceeded + Failed, // bad Url + HttpError, // some other HTTP error (eg. SSL failed) + Empty // for ping(), response is empty + }; + + RequestStatus( State s = Ok ) + : status( s ) + { + } + operator bool() const { return status == Ok; } + + State status; +}; + +DLLEXPORT QDebug& operator<<( QDebug& s, const RequestStatus& e ); + +class DLLEXPORT Manager : public QObject +{ + Q_OBJECT + Q_PROPERTY( bool hasInternet READ hasInternet NOTIFY hasInternetChanged FINAL ) + Q_PROPERTY( QVector< QUrl > checkInternetUrls READ getCheckInternetUrls WRITE setCheckHasInternetUrl ) + +public: + Manager(); + ~Manager() override; + + /** @brief Checks if the given @p url returns data. + * + * Returns a RequestStatus, which converts to @c true if the ping + * was successful. Other status reasons convert to @c false, + * typically because of no data, a Url error or no network access. + * + * May return Empty if the request was successful but returned + * no data at all. + */ + RequestStatus synchronousPing( const QUrl& url, const RequestOptions& options = RequestOptions() ); + + /** @brief Downloads the data from a given @p url + * + * Returns the data as a QByteArray, or an empty + * array if any error occurred (or no data was returned). + */ + QByteArray synchronousGet( const QUrl& url, const RequestOptions& options = RequestOptions() ); + + /** @brief Do a network request asynchronously. + * + * Returns a pointer to the reply-from-the-request. + * This may be a nullptr if an error occurs immediately. + * The caller is responsible for cleaning up the reply (eventually). + */ + QNetworkReply* asynchronousGet( const QUrl& url, const RequestOptions& options = RequestOptions() ); + + /// @brief Set the URL which is used for the general "is there internet" check. + static void setCheckHasInternetUrl( const QUrl& url ); + + /// @brief Adds an (extra) URL to check + static void addCheckHasInternetUrl( const QUrl& url ); + + /// @brief Set a collection of URLs used for the general "is there internet" check. + static void setCheckHasInternetUrl( const QVector< QUrl >& urls ); + + /// @brief What URLs are used to check for internet connectivity? + static QVector< QUrl > getCheckInternetUrls(); + +public Q_SLOTS: + /** @brief Do an explicit check for internet connectivity. + * + * This **may** do a ping to the configured check URL, but can also + * use other mechanisms. + */ + bool checkHasInternet(); + /** @brief Is there internet connectivity? + * + * This returns the result of the last explicit check, or if there + * is other information about the state of the internet connection, + * whatever is known. @c true means you can expect (all) internet + * connectivity to be present. + */ + bool hasInternet(); + +signals: + /** @brief Indicates that internet connectivity status has changed + * + * The value is that returned from hasInternet() -- @c true when there + * is connectivity, @c false otherwise. + */ + void hasInternetChanged( bool ); + +private: + class Private; +}; +} // namespace Network +} // namespace Calamares +#endif // LIBCALAMARES_NETWORK_MANAGER_H diff --git a/calamares/src/libcalamares/network/Tests.cpp b/calamares/src/libcalamares/network/Tests.cpp new file mode 100644 index 0000000..cba49ed --- /dev/null +++ b/calamares/src/libcalamares/network/Tests.cpp @@ -0,0 +1,147 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Tests.h" + +#include "Manager.h" +#include "utils/Logger.h" + +#include + +QTEST_GUILESS_MAIN( NetworkTests ) + +NetworkTests::NetworkTests() {} + +NetworkTests::~NetworkTests() {} + +void +NetworkTests::initTestCase() +{ +} + +void +NetworkTests::testInstance() +{ + Calamares::Network::Manager nam; + QVERIFY( !nam.hasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 0 ); +} + +void +NetworkTests::testPing() +{ + using namespace Calamares::Network; + Logger::setupLogLevel( Logger::LOGVERBOSE ); + Manager nam; + + // On FreeBSD, the SSL handling depends on the presence of root keys + // (from the ca_nss port) which may not be available. So HTTPS requests + // may fail with SSLVerificationError; this breaks the tests even if + // the point is to just check whether ping() works. + // + // So fall back to pinging example.com if the normal ping fails. + auto canPing_www_kde_org + = nam.synchronousPing( QUrl( "https://www.kde.org" ), RequestOptions( RequestOptions::FollowRedirect ) ); + cDebug() << "Ping:" << canPing_www_kde_org; + if ( canPing_www_kde_org.status == RequestStatus::HttpError ) + { + cDebug() << Logger::SubEntry << "Some HTTP failure, try example.com instead."; + auto canPing_example_com + = nam.synchronousPing( QUrl( "http://example.com" ), RequestOptions( RequestOptions::FollowRedirect ) ); + QVERIFY( canPing_example_com ); + } + else + { + QVERIFY( canPing_www_kde_org ); + } +} + +void +NetworkTests::testCheckUrl() +{ + using namespace Calamares::Network; + Logger::setupLogLevel( Logger::LOGVERBOSE ); + Manager nam; + + { + QUrl u( "http://example.com" ); + QVERIFY( u.isValid() ); + nam.setCheckHasInternetUrl( u ); + QVERIFY( nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 1 ); // Valid URL + } + { + QUrl u( "http://nonexistent.example.com" ); + QVERIFY( u.isValid() ); + nam.setCheckHasInternetUrl( u ); + QVERIFY( !nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 1 ); // Valid URL even if it doesn't resolve + } + { + QUrl u; + QVERIFY( !u.isValid() ); + nam.setCheckHasInternetUrl( u ); + QVERIFY( !nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 0 ); // Invalid URL tried + } +} + +void +NetworkTests::testCheckMultiUrl() +{ + using namespace Calamares::Network; + Logger::setupLogLevel( Logger::LOGVERBOSE ); + Manager nam; + + { + QUrl u0( "http://example.com" ); + QUrl u1( "https://kde.org" ); + QVERIFY( u0.isValid() ); + QVERIFY( u1.isValid() ); + nam.setCheckHasInternetUrl( { u0, u1 } ); + QVERIFY( nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 2 ); + } + { + QUrl u0( "http://nonexistent.example.com" ); + QUrl u1( "http://bogus.example.com" ); + QVERIFY( u0.isValid() ); + QVERIFY( u1.isValid() ); + nam.setCheckHasInternetUrl( { u0, u1 } ); + QVERIFY( !nam.checkHasInternet() ); + QVERIFY( !nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 2 ); // Both are valid URLs + nam.addCheckHasInternetUrl( QUrl( "http://example.com" ) ); + QVERIFY( nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 3 ); + } + { + QUrl u0( "http://nonexistent.example.com" ); + QUrl u1; + QVERIFY( u0.isValid() ); + QVERIFY( !u1.isValid() ); + nam.setCheckHasInternetUrl( { u0, u1 } ); + QVERIFY( !nam.checkHasInternet() ); + QVERIFY( !nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 1 ); // Only valid URL added + nam.addCheckHasInternetUrl( QUrl( "http://example.com" ) ); + QVERIFY( nam.checkHasInternet() ); + QCOMPARE( nam.getCheckInternetUrls().count(), 2 ); + } + { + QUrl u0( "http://nonexistent.example.com" ); + QUrl u1; + QVERIFY( u0.isValid() ); + QVERIFY( !u1.isValid() ); + nam.setCheckHasInternetUrl( { u1, u1, u1, u1 } ); + QCOMPARE( nam.getCheckInternetUrls().count(), 0 ); + nam.setCheckHasInternetUrl( { u1, u1, u0, u1 } ); + QCOMPARE( nam.getCheckInternetUrls().count(), 1 ); + } +} diff --git a/calamares/src/libcalamares/network/Tests.h b/calamares/src/libcalamares/network/Tests.h new file mode 100644 index 0000000..d72da57 --- /dev/null +++ b/calamares/src/libcalamares/network/Tests.h @@ -0,0 +1,32 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARES_NETWORK_TESTS_H +#define LIBCALAMARES_NETWORK_TESTS_H + +#include + +class NetworkTests : public QObject +{ + Q_OBJECT +public: + NetworkTests(); + ~NetworkTests() override; + +private Q_SLOTS: + void initTestCase(); + + void testInstance(); + void testPing(); + + void testCheckUrl(); + void testCheckMultiUrl(); +}; + +#endif diff --git a/calamares/src/libcalamares/packages/Globals.cpp b/calamares/src/libcalamares/packages/Globals.cpp new file mode 100644 index 0000000..ace172a --- /dev/null +++ b/calamares/src/libcalamares/packages/Globals.cpp @@ -0,0 +1,88 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Globals.h" + +#include "GlobalStorage.h" +#include "utils/Logger.h" + +static bool +additions( Calamares::GlobalStorage* gs, + const QString& key, + const QVariantList& installPackages, + const QVariantList& tryInstallPackages ) +{ + static const char PACKAGEOP[] = "packageOperations"; + + // Check if there's already a PACAKGEOP entry in GS, and if so we'll + // extend that one (overwriting the value in GS at the end of this method) + QVariantList packageOperations = gs->contains( PACKAGEOP ) ? gs->value( PACKAGEOP ).toList() : QVariantList(); + cDebug() << "Existing package operations length" << packageOperations.length(); + + // Clear out existing operations for this module, going backwards: + // Sometimes we remove an item, and we don't want the index to + // fall off the end of the list. + bool somethingRemoved = false; + for ( int index = packageOperations.length() - 1; 0 <= index; index-- ) + { + const QVariantMap op = packageOperations.at( index ).toMap(); + if ( op.contains( "source" ) && op.value( "source" ).toString() == key ) + { + cDebug() << Logger::SubEntry << "Removing existing operations for" << key; + packageOperations.removeAt( index ); + somethingRemoved = true; + } + } + + if ( !installPackages.empty() ) + { + QVariantMap op; + op.insert( "install", QVariant( installPackages ) ); + op.insert( "source", key ); + packageOperations.append( op ); + cDebug() << Logger::SubEntry << installPackages.length() << "critical packages."; + } + if ( !tryInstallPackages.empty() ) + { + QVariantMap op; + op.insert( "try_install", QVariant( tryInstallPackages ) ); + op.insert( "source", key ); + packageOperations.append( op ); + cDebug() << Logger::SubEntry << tryInstallPackages.length() << "non-critical packages."; + } + + if ( somethingRemoved || !packageOperations.isEmpty() ) + { + gs->insert( PACKAGEOP, packageOperations ); + return true; + } + return false; +} + +bool +Calamares::Packages::setGSPackageAdditions( Calamares::GlobalStorage* gs, + const Calamares::ModuleSystem::InstanceKey& module, + const QVariantList& installPackages, + const QVariantList& tryInstallPackages ) +{ + return additions( gs, module.toString(), installPackages, tryInstallPackages ); +} + +bool +Calamares::Packages::setGSPackageAdditions( Calamares::GlobalStorage* gs, + const Calamares::ModuleSystem::InstanceKey& module, + const QStringList& installPackages ) +{ + QVariantList l; + for ( const auto& s : installPackages ) + { + l << s; + } + return additions( gs, module.toString(), l, QVariantList() ); +} diff --git a/calamares/src/libcalamares/packages/Globals.h b/calamares/src/libcalamares/packages/Globals.h new file mode 100644 index 0000000..ad8d15e --- /dev/null +++ b/calamares/src/libcalamares/packages/Globals.h @@ -0,0 +1,45 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARES_PACKAGES_GLOBALS_H +#define LIBCALAMARES_PACKAGES_GLOBALS_H + +#include "DllMacro.h" +#include "GlobalStorage.h" +#include "modulesystem/InstanceKey.h" + +namespace Calamares +{ +namespace Packages +{ +/** @brief Sets the install-packages GS keys for the given module + * + * This replaces previously-set install-packages lists for the + * given module by the two new lists. + * + * Returns @c true if anything was changed, @c false otherwise. + */ +DLLEXPORT bool setGSPackageAdditions( Calamares::GlobalStorage* gs, + const Calamares::ModuleSystem::InstanceKey& module, + const QVariantList& installPackages, + const QVariantList& tryInstallPackages ); +/** @brief Sets the install-packages GS keys for the given module + * + * This replaces previously-set install-packages lists. Use this with + * plain lists of package names. It does not support try-install. + */ +DLLEXPORT bool setGSPackageAdditions( Calamares::GlobalStorage* gs, + const Calamares::ModuleSystem::InstanceKey& module, + const QStringList& installPackages ); +// void setGSPackageRemovals( const Calamares::ModuleSystem::InstanceKey& key, const QVariantList& removePackages ); +} // namespace Packages +} // namespace Calamares + + +#endif diff --git a/calamares/src/libcalamares/packages/Tests.cpp b/calamares/src/libcalamares/packages/Tests.cpp new file mode 100644 index 0000000..d4b8696 --- /dev/null +++ b/calamares/src/libcalamares/packages/Tests.cpp @@ -0,0 +1,236 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Globals.h" + +#include "GlobalStorage.h" +#include "utils/Logger.h" + +#include + +class PackagesTests : public QObject +{ + Q_OBJECT +public: + PackagesTests() {} + ~PackagesTests() override {} +private Q_SLOTS: + void initTestCase(); + + void testEmpty(); + void testAdd_data(); + /** @brief Test various add calls, for a "clean" GS + * + * Check that adding through the variant- and the stringlist-API + * does the same thing. + */ + void testAdd(); + /// Test replacement and mixing string-list with variant calls + void testAddMixed(); +}; + +void +PackagesTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +PackagesTests::testEmpty() +{ + Calamares::GlobalStorage gs; + const QString topKey( "packageOperations" ); + Calamares::ModuleSystem::InstanceKey k( "this", "that" ); + + QVERIFY( !gs.contains( topKey ) ); + QCOMPARE( k.toString(), "this@that" ); + + // Adding nothing at all does nothing + QVERIFY( !Calamares::Packages::setGSPackageAdditions( &gs, k, QVariantList(), QVariantList() ) ); + QVERIFY( !gs.contains( topKey ) ); + + QVERIFY( !Calamares::Packages::setGSPackageAdditions( &gs, k, QStringList() ) ); + QVERIFY( !gs.contains( topKey ) ); +} + +void +PackagesTests::testAdd_data() +{ + QTest::addColumn< QStringList >( "packages" ); + + QTest::newRow( "one" ) << QStringList { QString( "vim" ) }; + QTest::newRow( "two" ) << QStringList { QString( "vim" ), QString( "emacs" ) }; + QTest::newRow( "one-again" ) << QStringList { QString( "nano" ) }; + QTest::newRow( "six" ) << QStringList { QString( "vim" ), QString( "emacs" ), QString( "nano" ), + QString( "kate" ), QString( "gedit" ), QString( "sublime" ) }; + // There is no "de-duplication" so this will insert "cim" twice + QTest::newRow( "dups" ) << QStringList { QString( "cim" ), QString( "vim" ), QString( "cim" ) }; +} + +void +PackagesTests::testAdd() +{ + Calamares::GlobalStorage gs; + + const QString extraEditor( "notepad++" ); + const QString topKey( "packageOperations" ); + Calamares::ModuleSystem::InstanceKey k( "this", "that" ); + Calamares::ModuleSystem::InstanceKey otherInstance( "this", "other" ); + + QFETCH( QStringList, packages ); + QVERIFY( !packages.contains( extraEditor ) ); + + { + QVERIFY( !gs.contains( topKey ) ); + QVERIFY( Calamares::Packages::setGSPackageAdditions( &gs, k, QVariant( packages ).toList(), QVariantList() ) ); + QVERIFY( gs.contains( topKey ) ); + auto actionList = gs.value( topKey ).toList(); + QCOMPARE( actionList.length(), 1 ); + auto action = actionList[ 0 ].toMap(); + QVERIFY( action.contains( "install" ) ); + auto op = action[ "install" ].toList(); + QCOMPARE( op.length(), packages.length() ); + for ( const auto& s : std::as_const( packages ) ) + { + QVERIFY( op.contains( s ) ); + } + cDebug() << op; + } + { + QVERIFY( Calamares::Packages::setGSPackageAdditions( &gs, otherInstance, packages ) ); + QVERIFY( gs.contains( topKey ) ); + auto actionList = gs.value( topKey ).toList(); + QCOMPARE( actionList.length(), 2 ); // One for each instance key! + auto action = actionList[ 0 ].toMap(); + auto secondaction = actionList[ 1 ].toMap(); + auto op = action[ "install" ].toList(); + auto secondop = secondaction[ "install" ].toList(); + QCOMPARE( op, secondop ); + } + + { + // Replace one and expect differences + packages << extraEditor; + QVERIFY( Calamares::Packages::setGSPackageAdditions( &gs, otherInstance, packages ) ); + QVERIFY( gs.contains( topKey ) ); + auto actionList = gs.value( topKey ).toList(); + QCOMPARE( actionList.length(), 2 ); // One for each instance key! + for ( const auto& actionVariant : std::as_const( actionList ) ) + { + auto action = actionVariant.toMap(); + QVERIFY( action.contains( "install" ) ); + QVERIFY( action.contains( "source" ) ); + if ( action[ "source" ].toString() == otherInstance.toString() ) + { + auto op = action[ "install" ].toList(); + QCOMPARE( op.length(), packages.length() ); // changed from original length, though + for ( const auto& s : std::as_const( packages ) ) + { + QVERIFY( op.contains( s ) ); + } + } + else + { + // This is the "original" instance, so it's missing extraEditor + auto op = action[ "install" ].toList(); + QCOMPARE( op.length(), packages.length() - 1 ); // changed from original length + QVERIFY( !op.contains( extraEditor ) ); + } + } + } +} + +void +PackagesTests::testAddMixed() +{ + Calamares::GlobalStorage gs; + + const QString extraEditor( "notepad++" ); + const QString topKey( "packageOperations" ); + Calamares::ModuleSystem::InstanceKey k( "this", "that" ); + Calamares::ModuleSystem::InstanceKey otherInstance( "this", "other" ); + + // Just one + { + QVERIFY( !gs.contains( topKey ) ); + QVERIFY( + Calamares::Packages::setGSPackageAdditions( &gs, k, QVariantList { QString( "vim" ) }, QVariantList() ) ); + QVERIFY( gs.contains( topKey ) ); + auto actionList = gs.value( topKey ).toList(); + QCOMPARE( actionList.length(), 1 ); + auto action = actionList[ 0 ].toMap(); + QVERIFY( action.contains( "install" ) ); + auto op = action[ "install" ].toList(); + QCOMPARE( op.length(), 1 ); + QCOMPARE( op[ 0 ], QString( "vim" ) ); + cDebug() << op; + } + + // Replace with two packages + { + QVERIFY( Calamares::Packages::setGSPackageAdditions( + &gs, k, QVariantList { QString( "vim" ), QString( "emacs" ) }, QVariantList() ) ); + QVERIFY( gs.contains( topKey ) ); + auto actionList = gs.value( topKey ).toList(); + QCOMPARE( actionList.length(), 1 ); + auto action = actionList[ 0 ].toMap(); + QVERIFY( action.contains( "install" ) ); + auto op = action[ "install" ].toList(); + QCOMPARE( op.length(), 2 ); + QCOMPARE( action[ "source" ].toString(), k.toString() ); + QVERIFY( op.contains( QString( "vim" ) ) ); + QVERIFY( op.contains( QString( "emacs" ) ) ); + cDebug() << op; + } + + // Replace with one (different) package + { + QVERIFY( + Calamares::Packages::setGSPackageAdditions( &gs, k, QVariantList { QString( "nano" ) }, QVariantList() ) ); + QVERIFY( gs.contains( topKey ) ); + auto actionList = gs.value( topKey ).toList(); + QCOMPARE( actionList.length(), 1 ); + auto action = actionList[ 0 ].toMap(); + QVERIFY( action.contains( "install" ) ); + auto op = action[ "install" ].toList(); + QCOMPARE( op.length(), 1 ); + QCOMPARE( action[ "source" ].toString(), k.toString() ); + QCOMPARE( op[ 0 ], QString( "nano" ) ); + cDebug() << op; + } + + // Now we have two sources + { + QVERIFY( Calamares::Packages::setGSPackageAdditions( &gs, otherInstance, QStringList( extraEditor ) ) ); + QVERIFY( gs.contains( topKey ) ); + auto actionList = gs.value( topKey ).toList(); + QCOMPARE( actionList.length(), 2 ); + + for ( const auto& actionVariant : std::as_const( actionList ) ) + { + auto action = actionVariant.toMap(); + QVERIFY( action.contains( "install" ) ); + QVERIFY( action.contains( "source" ) ); + if ( action[ "source" ].toString() == otherInstance.toString() ) + { + auto op = action[ "install" ].toList(); + QCOMPARE( op.length(), 1 ); + QVERIFY( + op.contains( action[ "source" ] == otherInstance.toString() ? extraEditor : QString( "nano" ) ) ); + } + } + } +} + + +QTEST_GUILESS_MAIN( PackagesTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/libcalamares/partition/AutoMount.cpp b/calamares/src/libcalamares/partition/AutoMount.cpp new file mode 100644 index 0000000..c21f781 --- /dev/null +++ b/calamares/src/libcalamares/partition/AutoMount.cpp @@ -0,0 +1,176 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + */ + +#include "AutoMount.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" + +#include + +#include + +namespace Calamares +{ +namespace Partition +{ + +struct AutoMountInfo +{ + bool hasSolid = false; + bool wasSolidModuleAutoLoaded = false; +}; + +/** @section Solid + * + * KDE Solid automount management. + * + * Solid can be influenced through DBus calls to kded (both kded5 and kded6). The + * following code handles Solid: if Solid exists (e.g. we're in a KDE Plasma desktop) + * then try to turn off automount that way. + */ + +/** @brief Boilerplate for a call to kded + * + * Returns a method-call message, ready for arguments and call(). + */ +static inline QDBusMessage +kdedCall( const QString& method ) +{ +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + return QDBusMessage::createMethodCall( + QStringLiteral( "org.kde.kded5" ), QStringLiteral( "/kded" ), QStringLiteral( "org.kde.kded5" ), method ); +#else + return QDBusMessage::createMethodCall( + QStringLiteral( "org.kde.kded6" ), QStringLiteral( "/kded" ), QStringLiteral( "org.kde.kded6" ), method ); +#endif +} + +/** @brief Log a response from call() + * + * Logs without a function header so it is simple to use from an existing + * logging-block. Assumes @p r is a reply or an error message. + * + * @internal + */ +static void +logDBusResponse( QDBusMessage&& r ) +{ + if ( r.type() == QDBusMessage::ReplyMessage ) + { + cDebug() << Logger::SubEntry << r.type() << "reply" << r.arguments(); + } + else + { + cDebug() << Logger::SubEntry << r.type() << "error" << r.errorMessage(); + } +} + +/** @brief Enables (or disables) automount for Solid + * + * If @p enable is @c true, enables automount. Otherwise, disables it. + * This throws some DBbus messages on the wire and forgets about them. + */ +// This code comes, roughly, from the KCM for removable devices. +static void +enableSolidAutoMount( QDBusConnection& dbus, bool enable ) +{ + const auto moduleName = QVariant( QStringLiteral( "device_automounter" ) ); + + // Stop module from auto-loading + { + auto msg = kdedCall( QStringLiteral( "setModuleAutoloading" ) ); + msg.setArguments( { moduleName, QVariant( enable ) } ); + logDBusResponse( dbus.call( msg, QDBus::Block ) ); + } + + // Stop module + { + auto msg = kdedCall( enable ? QStringLiteral( "loadModule" ) : QStringLiteral( "unloadModule" ) ); + msg.setArguments( { moduleName } ); + logDBusResponse( dbus.call( msg, QDBus::Block ) ); + } +} + +/** @brief Check if Solid exists and has automount set + * + * Updates the @p info object with the discovered information. + * - if there is no Solid available on DBus, sets hasSolid to @c false + * - if there is Solid available on DBusm, sets *hasSolid* to @c true + * and places the queried value of automounting in *wasSolidModuleAutoLoaded*. + */ +static void +querySolidAutoMount( QDBusConnection& dbus, AutoMountInfo& info ) +{ + const auto moduleName = QVariant( QStringLiteral( "device_automounter" ) ); + + // Find previous setting; this **does** need to block + auto msg = kdedCall( QStringLiteral( "isModuleAutoloaded" ) ); + msg.setArguments( { moduleName } ); + std::optional< bool > result; + QDBusMessage r = dbus.call( msg, QDBus::Block ); + if ( r.type() == QDBusMessage::ReplyMessage ) + { + auto arg = r.arguments(); + if ( arg.length() == 1 ) + { + auto v = arg.at( 0 ); + if ( v.isValid() && Calamares::typeOf( v ) == Calamares::BoolVariantType ) + { + result = v.toBool(); + } + } + if ( !result.has_value() ) + { + cDebug() << "No viable response from Solid" << r.path(); + } + } + else + { + // It's an error message + cDebug() << "Solid not available:" << r.errorMessage(); + } + info.hasSolid = result.has_value(); + info.wasSolidModuleAutoLoaded = result.has_value() ? result.value() : false; +} + +std::shared_ptr< AutoMountInfo > +automountDisable( bool disable ) +{ + auto info = std::make_shared< AutoMountInfo >(); + QDBusConnection dbus = QDBusConnection::sessionBus(); + + // KDE Plasma (Solid) handling + querySolidAutoMount( dbus, *info ); + if ( info->hasSolid ) + { + cDebug() << "Setting Solid automount to" << ( disable ? "disabled" : "enabled" ); + enableSolidAutoMount( dbus, !disable ); + } + + // TODO: other environments + return info; +} + + +void +automountRestore( const std::shared_ptr< AutoMountInfo >& info ) +{ + QDBusConnection dbus = QDBusConnection::sessionBus(); + + // KDE Plasma (Solid) handling + if ( info->hasSolid ) + { + enableSolidAutoMount( dbus, info->wasSolidModuleAutoLoaded ); + } + + // TODO: other environments +} + +} // namespace Partition +} // namespace Calamares diff --git a/calamares/src/libcalamares/partition/AutoMount.h b/calamares/src/libcalamares/partition/AutoMount.h new file mode 100644 index 0000000..de8bc4c --- /dev/null +++ b/calamares/src/libcalamares/partition/AutoMount.h @@ -0,0 +1,51 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef PARTITION_AUTOMOUNT_H +#define PARTITION_AUTOMOUNT_H + +#include "DllMacro.h" + +#include + +namespace Calamares +{ +namespace Partition +{ + +struct AutoMountInfo; + +/** @brief Disable automount + * + * Various subsystems can do "agressive automount", which can get in the + * way of partitioning actions. In particular, Solid can be configured + * to automount every device it sees, and partitioning happens in multiple + * steps (create table, create partition, set partition flags) which are + * blocked if the partition gets mounted partway through the operation. + * + * @param disable set this to false to reverse the sense of the function + * call and force *enabling* automount, instead. + * + * Returns an opaque structure which can be passed to automountRestore() + * to return the system to the previously-configured automount settings. + */ +DLLEXPORT std::shared_ptr< AutoMountInfo > automountDisable( bool disable = true ); + +/** @brief Restore automount settings + * + * Pass the value returned from automountDisable() to restore the + * previous settings. + */ +DLLEXPORT void automountRestore( const std::shared_ptr< AutoMountInfo >& t ); + +} // namespace Partition +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/partition/FileSystem.cpp b/calamares/src/libcalamares/partition/FileSystem.cpp new file mode 100644 index 0000000..3984dff --- /dev/null +++ b/calamares/src/libcalamares/partition/FileSystem.cpp @@ -0,0 +1,87 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "FileSystem.h" + +#include + +namespace Calamares +{ +namespace Partition +{ + +QString +prettyNameForFileSystemType( FileSystem::Type t ) +{ + QT_WARNING_PUSH + QT_WARNING_DISABLE_CLANG( "-Wswitch-enum" ) + // 13 enumeration values not handled + switch ( t ) + { + case FileSystem::Unknown: + return QObject::tr( "unknown", "@partition info" ); + case FileSystem::Extended: + return QObject::tr( "extended", "@partition info" ); + case FileSystem::Unformatted: + return QObject::tr( "unformatted", "@partition info" ); + case FileSystem::LinuxSwap: + return QObject::tr( "swap", "@partition info" ); + case FileSystem::Fat16: + case FileSystem::Fat32: + case FileSystem::Ntfs: + case FileSystem::Xfs: + case FileSystem::Jfs: + case FileSystem::Hfs: + case FileSystem::Ufs: + case FileSystem::Hpfs: + case FileSystem::Luks: + case FileSystem::Luks2: + case FileSystem::Ocfs2: + case FileSystem::Zfs: + case FileSystem::Nilfs2: + return FileSystem::nameForType( t ).toUpper(); + case FileSystem::ReiserFS: + return "ReiserFS"; + case FileSystem::Reiser4: + return "Reiser4"; + case FileSystem::HfsPlus: + return "HFS+"; + case FileSystem::Btrfs: + return "Btrfs"; + case FileSystem::Exfat: + return "exFAT"; + case FileSystem::Lvm2_PV: + return "LVM PV"; + default: + return FileSystem::nameForType( t ); + } + QT_WARNING_POP +} + +QString +untranslatedFS( FileSystem::Type t ) +{ + QT_WARNING_PUSH + QT_WARNING_DISABLE_CLANG( "-Wswitch-enum" ) + // 34 enumeration values not handled + switch ( t ) + { + case FileSystem::Type::ReiserFS: + return QStringLiteral( "reiserfs" ); + default: + return FileSystem::nameForType( t, { QStringLiteral( "C" ) } ); + } + QT_WARNING_POP +} + +} // namespace Partition +} // namespace Calamares diff --git a/calamares/src/libcalamares/partition/FileSystem.h b/calamares/src/libcalamares/partition/FileSystem.h new file mode 100644 index 0000000..36291e7 --- /dev/null +++ b/calamares/src/libcalamares/partition/FileSystem.h @@ -0,0 +1,100 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/* + * NOTE: this functionality is only available when Calamares is compiled + * with KPMcore support. + */ + +#ifndef PARTITION_FILESYSTEM_H +#define PARTITION_FILESYSTEM_H + +#include "DllMacro.h" +#include "partition/Global.h" + +#include + +namespace Calamares +{ +namespace Partition +{ +QString DLLEXPORT prettyNameForFileSystemType( FileSystem::Type t ); + +/** @brief Returns a machine-readable identifier for the filesystem type + * + * This identifier is used in filesystem manipulation -- + * e.g. when mounting the filesystem, or in /etc/fstab. It + * is almost always just what KPMCore says it is, with + * the following exceptions: + * - reiserfs is called "reiser" by KPMCore, "reiserfs" by Calamares + */ +QString DLLEXPORT untranslatedFS( FileSystem::Type t ); + +/** @brief Returns the machine-readable identifier for the given @p fs + * + * See notes for untranslatedFS(), above. + */ +static inline QString +untranslatedFS( FileSystem& fs ) +{ + return untranslatedFS( fs.type() ); +} + +/** @brief Returns a machine-readable identifier for the given @p fs + * + * Returns an empty string is the @p fs is not valid (e.g. nullptr). + */ +static inline QString +untranslatedFS( FileSystem* fs ) +{ + return fs ? untranslatedFS( *fs ) : QString(); +} + +static inline QString +userVisibleFS( FileSystem& fs ) +{ + return fs.name(); +} + +static inline QString +userVisibleFS( FileSystem* fs ) +{ + return fs ? userVisibleFS( *fs ) : QString(); +} + +/** @brief Mark a particular filesystem type as used (or not) + * + * See useFilesystemGS(const QString&, bool); this method uses the filesystem type + * enumeration to pick the name. (The other implementation is in `Global.h` + * because it touches Global Storage, but this one needs KPMcore) + */ +inline void +useFilesystemGS( FileSystem::Type filesystem, bool used ) +{ + useFilesystemGS( untranslatedFS( filesystem ), used ); +} + +/* @brief Reads from global storage whether the typesystem type is used + * + * See isFilesystemUsedGS(const QString&). (The other implementation is in `Global.h` + * because it touches Global Storage, but this one needs KPMcore) + */ +inline bool +isFilesystemUsedGS( FileSystem::Type filesystem ) +{ + return isFilesystemUsedGS( untranslatedFS( filesystem ) ); +} + +} // namespace Partition +} // namespace Calamares + +#endif // PARTITION_PARTITIONQUERY_H diff --git a/calamares/src/libcalamares/partition/Global.cpp b/calamares/src/libcalamares/partition/Global.cpp new file mode 100644 index 0000000..9b01108 --- /dev/null +++ b/calamares/src/libcalamares/partition/Global.cpp @@ -0,0 +1,55 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#include "Global.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include + +static const QString fsUse_key = QStringLiteral( "filesystem_use" ); + +bool +Calamares::Partition::isFilesystemUsedGS( const Calamares::GlobalStorage* gs, const QString& filesystemType ) +{ + if ( !gs ) + { + return false; + } + const QVariantMap fsUse = gs->value( fsUse_key ).toMap(); + QString key = filesystemType.toLower(); + if ( fsUse.contains( key ) ) + { + const auto v = fsUse.value( key ); + return v.toBool(); + } + return false; +} + +void +Calamares::Partition::useFilesystemGS( Calamares::GlobalStorage* gs, const QString& filesystemType, bool used ) +{ + if ( gs ) + { + QVariantMap existingMap = gs->contains( fsUse_key ) ? gs->value( fsUse_key ).toMap() : QVariantMap(); + QString key = filesystemType.toLower(); + existingMap.insert( key, used ); + gs->insert( fsUse_key, existingMap ); + } +} + +void +Calamares::Partition::clearFilesystemGS( Calamares::GlobalStorage* gs ) +{ + if ( gs ) + { + gs->remove( fsUse_key ); + } +} diff --git a/calamares/src/libcalamares/partition/Global.h b/calamares/src/libcalamares/partition/Global.h new file mode 100644 index 0000000..cef1ecb --- /dev/null +++ b/calamares/src/libcalamares/partition/Global.h @@ -0,0 +1,78 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/* + * This is the API for manipulating Global Storage keys related to + * filesystems and partitions. This does **not** depend on KPMcore. + */ + +#ifndef PARTITION_GLOBAL_H +#define PARTITION_GLOBAL_H + +#include "DllMacro.h" +#include "JobQueue.h" + +namespace Calamares +{ +namespace Partition +{ +/** @brief Mark a particular filesystem type as used (or not) + * + * Filesystems are marked used (or not) in the global storage + * key *filesystem_use*. Sub-keys are the filesystem name, + * and the values are boolean; filesystems that are used in + * the target system are marked with @c true. Unused filesystems + * may be unmarked, or may be marked @c false. + * + * The filesystem name should be the untranslated name. Filesystem + * names are **lower**cased when used as keys. + */ +void DLLEXPORT useFilesystemGS( Calamares::GlobalStorage* gs, const QString& filesystemType, bool used ); +/** @brief Reads from global storage whether the filesystem type is used + * + * Reads from the global storage key *filesystem_use* and returns + * the boolean value stored in subkey @p filesystemType. Returns + * @c false if the subkey is not set at all. + * + * The filesystem name should be the untranslated name. Filesystem + * names are **lower**cased when used as keys. + */ +bool DLLEXPORT isFilesystemUsedGS( const Calamares::GlobalStorage* gs, const QString& filesystemType ); + +/** @brief Clears the usage data for filesystems + * + * This removes the internal key *filesystem_use*. + */ +void DLLEXPORT clearFilesystemGS( Calamares::GlobalStorage* gs ); + +/** @brief Convenience function for using "the" Global Storage + * + * @see useFilesystemGS(const QString&, bool) + */ +inline void +useFilesystemGS( const QString& filesystemType, bool used ) +{ + useFilesystemGS( Calamares::JobQueue::instanceGlobalStorage(), filesystemType, used ); +} + +/** @brief Convenience function for using "the" Global Storage + * + * @see isFilesystemUsedGS(const QString&); + */ +inline bool +isFilesystemUsedGS( const QString& filesystemType ) +{ + return isFilesystemUsedGS( Calamares::JobQueue::instanceGlobalStorage(), filesystemType ); +} + +} // namespace Partition +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/partition/KPMHelper.h b/calamares/src/libcalamares/partition/KPMHelper.h new file mode 100644 index 0000000..0a4aaee --- /dev/null +++ b/calamares/src/libcalamares/partition/KPMHelper.h @@ -0,0 +1,43 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* + * KPMCore header file inclusion. + * + * Includes the system KPMCore headers without warnings (by switching off + * the expected warnings). + */ +#ifndef PARTITION_KPMHELPER_H +#define PARTITION_KPMHELPER_H + +#include + +// The kpmcore headers are not C++17 warning-proof, especially +// with picky compilers like Clang 10. Since we use Clang for the +// find-all-the-warnings case, switch those warnings off for +// the we-can't-change-them system headers. +QT_WARNING_PUSH +QT_WARNING_DISABLE_CLANG( "-Wdocumentation" ) +QT_WARNING_DISABLE_CLANG( "-Wsuggest-destructor-override" ) +QT_WARNING_DISABLE_CLANG( "-Winconsistent-missing-destructor-override" ) +// Because of __lastType +QT_WARNING_DISABLE_CLANG( "-Wreserved-identifier" ) + +#include +#include +#include +#include +#include +#include +#include +#include + +QT_WARNING_POP + +#endif diff --git a/calamares/src/libcalamares/partition/KPMManager.cpp b/calamares/src/libcalamares/partition/KPMManager.cpp new file mode 100644 index 0000000..c82bc68 --- /dev/null +++ b/calamares/src/libcalamares/partition/KPMManager.cpp @@ -0,0 +1,100 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "KPMManager.h" + +#include "utils/Logger.h" + +#include +#include +#include + +#include + + +namespace Calamares +{ +namespace Partition +{ +class InternalManager +{ +public: + InternalManager(); +}; + +static bool s_kpm_loaded = false; + +/* + * We have one living InternalManager object at a time. + * It is managed by shared_ptr<>s help by KPMManager + * objects, but since we can create KPMManager objects + * independent of each other, all of which share ownership + * of the same InternalManager, hang on to one extra reference + * to the InternalManager so we can hand it out in getInternal(). + */ +static std::weak_ptr< InternalManager > s_backend; + +InternalManager::InternalManager() +{ + cDebug() << "KPMCore backend starting .."; + + Q_ASSERT( s_backend.expired() ); + + if ( !s_kpm_loaded ) + { + QByteArray backendName = qgetenv( "KPMCORE_BACKEND" ); + if ( !CoreBackendManager::self()->load( backendName.isEmpty() ? CoreBackendManager::defaultBackendName() + : backendName ) ) + { + cWarning() << "Failed to load backend plugin" << backendName; + } + else + { + auto* backend_p = CoreBackendManager::self()->backend(); + cDebug() << Logger::SubEntry << "Backend" << Logger::Pointer( backend_p ) << backend_p->id() + << backend_p->version(); + s_kpm_loaded = true; + } + } +} + +std::shared_ptr< InternalManager > +getInternal() +{ + if ( s_backend.expired() ) + { + auto p = std::make_shared< InternalManager >(); + s_backend = p; + return p; + } + return s_backend.lock(); +} + +KPMManager::KPMManager() + : m_d( getInternal() ) +{ +} + +KPMManager::~KPMManager() {} + +KPMManager::operator bool() const +{ + return s_kpm_loaded; +} + +CoreBackend* +KPMManager::backend() const +{ + return s_kpm_loaded ? CoreBackendManager::self()->backend() : nullptr; +} + + +} // namespace Partition +} // namespace Calamares diff --git a/calamares/src/libcalamares/partition/KPMManager.h b/calamares/src/libcalamares/partition/KPMManager.h new file mode 100644 index 0000000..45a403b --- /dev/null +++ b/calamares/src/libcalamares/partition/KPMManager.h @@ -0,0 +1,63 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/* + * NOTE: this functionality is only available when Calamares is compiled + * with KPMcore support. + */ + +#ifndef PARTITION_KPMMANAGER_H +#define PARTITION_KPMMANAGER_H + +#include "DllMacro.h" + +#include + +class CoreBackend; + +namespace Calamares +{ +namespace Partition +{ +/// @brief Handle to KPMCore +class InternalManager; + +/** @brief KPMCore loader and cleanup + * + * A Calamares plugin that uses KPMCore should hold an object of + * this class; its only responsibility is to load KPMCore + * and to cleanly unload it on destruction (with KPMCore 4, + * also to shutdown the privileged helper application). + * + * It loads the default plugin ("parted" with KPMCore 3, "sfdisk" + * with KPMCore 4), but this can be overridden by setting the + * environment variable KPMCORE_BACKEND. Setting it to + * "dummy" will load the dummy plugin instead. + */ +class DLLEXPORT KPMManager +{ +public: + KPMManager(); + ~KPMManager(); + + /// @brief Is KPMCore loaded correctly? + operator bool() const; + + /// @brief Gets the KPMCore backend (e.g. CoreBackendManager::self()->backend() ) + CoreBackend* backend() const; + +private: + std::shared_ptr< InternalManager > m_d; +}; + +} // namespace Partition +} // namespace Calamares + +#endif // PARTITION_KPMMANAGER_H diff --git a/calamares/src/libcalamares/partition/KPMTests.cpp b/calamares/src/libcalamares/partition/KPMTests.cpp new file mode 100644 index 0000000..9106279 --- /dev/null +++ b/calamares/src/libcalamares/partition/KPMTests.cpp @@ -0,0 +1,118 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "utils/Logger.h" + +#include "FileSystem.h" + +#include +#include + +#include + +class KPMTests : public QObject +{ + Q_OBJECT +public: + KPMTests(); + ~KPMTests() override; +private Q_SLOTS: + void initTestCase(); + + void testFlagNames(); + void testFSNames(); +}; + +KPMTests::KPMTests() {} + +KPMTests::~KPMTests() {} + +void +KPMTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +KPMTests::testFlagNames() +{ + cDebug() << "Partition flags according to KPMCore:"; + int f = 1; + QStringList names; + QString s; + while ( !( s = PartitionTable::flagName( static_cast< PartitionTable::Flag >( f ) ) ).isEmpty() ) + { + cDebug() << Logger::SubEntry << f << s; + names.append( s ); + + f <<= 1; + } + + QCOMPARE( PartitionTable::flagName( static_cast< PartitionTable::Flag >( 1 ) ), QStringLiteral( "boot" ) ); + + // KPMCore 4 unifies the flags and handles them internally + QCOMPARE( PartitionTable::flagName( PartitionTable::Flag::Boot ), QStringLiteral( "boot" ) ); + QVERIFY( names.contains( QStringLiteral( "boot" ) ) ); + QVERIFY( !names.contains( QStringLiteral( "esp" ) ) ); +} + +void +KPMTests::testFSNames() +{ + cDebug() << "FileSystem names according to KPMCore:"; + + // This uses KPMCore directly, rather than Calamares partition/FileSystem.h + // which doesn't wrap nameForType() -- it just provides more meaningful + // names for FS naming on FileSystem objects. + QStringList fsNames; + const auto fstypes = FileSystem::types(); + fsNames.reserve( fstypes.count() ); + for ( const auto t : fstypes ) + { + QString s = FileSystem::nameForType( t, { "C" } ); // Untranslated + cDebug() << Logger::SubEntry << s; + fsNames.append( s ); + } + + QVERIFY( fsNames.contains( "ext2" ) ); + QVERIFY( fsNames.contains( "ext4" ) ); + QVERIFY( fsNames.contains( "reiser" ) ); + + QStringList calaFSNames; + calaFSNames.reserve( fstypes.count() ); + for ( const auto t : fstypes ) + { + QString s = Calamares::Partition::untranslatedFS( t ); + calaFSNames.append( s ); + } + + QVERIFY( calaFSNames.contains( "ext2" ) ); + QVERIFY( calaFSNames.contains( "ext4" ) ); + QVERIFY( !calaFSNames.contains( "reiser" ) ); + QVERIFY( calaFSNames.contains( "reiserfs" ) ); // whole point of Cala's own implementation + + // Lists are the same except for .. the exceptions + QStringList exceptionalNames { "reiser", "reiserfs" }; + for ( const auto& s : fsNames ) + { + QVERIFY( exceptionalNames.contains( s ) || calaFSNames.contains( s ) ); + } + for ( const auto& s : calaFSNames ) + { + QVERIFY( exceptionalNames.contains( s ) || fsNames.contains( s ) ); + } +} + + +QTEST_GUILESS_MAIN( KPMTests ) + +#include "utils/moc-warnings.h" + +#include "KPMTests.moc" diff --git a/calamares/src/libcalamares/partition/Mount.cpp b/calamares/src/libcalamares/partition/Mount.cpp new file mode 100644 index 0000000..03b776f --- /dev/null +++ b/calamares/src/libcalamares/partition/Mount.cpp @@ -0,0 +1,159 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Mount.h" + +#include "partition/Sync.h" +#include "utils/Logger.h" +#include "utils/String.h" +#include "utils/System.h" + +#include +#include + +namespace Calamares +{ +namespace Partition +{ + +int +mount( const QString& devicePath, const QString& mountPoint, const QString& filesystemName, const QString& options ) +{ + if ( devicePath.isEmpty() || mountPoint.isEmpty() ) + { + if ( devicePath.isEmpty() ) + { + cWarning() << "Can't mount an empty device."; + } + if ( mountPoint.isEmpty() ) + { + cWarning() << "Can't mount on an empty mountpoint."; + } + + return static_cast< int >( Calamares::ProcessResult::Code::NoWorkingDirectory ); + } + + QDir mountPointDir( mountPoint ); + if ( !mountPointDir.exists() ) + { + bool ok = mountPointDir.mkpath( mountPoint ); + if ( !ok ) + { + cWarning() << "Could not create mountpoint" << mountPoint; + return static_cast< int >( Calamares::ProcessResult::Code::NoWorkingDirectory ); + } + } + + QStringList args = { "mount" }; + + if ( !filesystemName.isEmpty() ) + { + args << "-t" << filesystemName; + } + if ( !options.isEmpty() ) + { + if ( options.startsWith( '-' ) ) + { + args << options; + } + else + { + args << "-o" << options; + } + } + args << devicePath << mountPoint; + + auto r = Calamares::System::runCommand( args, std::chrono::seconds( 10 ) ); + sync(); + return r.getExitCode(); +} + +int +unmount( const QString& path, const QStringList& options ) +{ + auto r = Calamares::System::runCommand( QStringList { "umount" } << options << path, std::chrono::seconds( 10 ) ); + sync(); + return r.getExitCode(); +} + +struct TemporaryMount::Private +{ + QString m_devicePath; + QTemporaryDir m_mountDir; +}; + +TemporaryMount::TemporaryMount( const QString& devicePath, const QString& filesystemName, const QString& options ) + : m_d( std::make_unique< Private >() ) +{ + m_d->m_devicePath = devicePath; + m_d->m_mountDir.setAutoRemove( false ); + int r = mount( devicePath, m_d->m_mountDir.path(), filesystemName, options ); + if ( r ) + { + cWarning() << "Mount of" << devicePath << "on" << m_d->m_mountDir.path() << "failed, code" << r; + m_d.reset(); + } +} + +TemporaryMount::~TemporaryMount() +{ + if ( m_d ) + { + int r = unmount( m_d->m_mountDir.path(), { "-R" } ); + if ( r ) + { + cWarning() << "UnMount of temporary" << m_d->m_devicePath << "on" << m_d->m_mountDir.path() + << "failed, code" << r; + } + } +} + +QString +TemporaryMount::path() const +{ + return m_d ? m_d->m_mountDir.path() : QString(); +} + +QList< MtabInfo > +MtabInfo::fromMtabFilteredByPrefix( const QString& mountPrefix, const QString& mtabPath ) +{ + QFile f( mtabPath.isEmpty() ? "/etc/mtab" : mtabPath ); + if ( !f.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + return {}; + } + + QList< MtabInfo > l; + // After opening, atEnd() is already true (!?) so try reading at least once + do + { + QString line = f.readLine(); + if ( line.isEmpty() || line.startsWith( '#' ) ) + { + continue; + } + + QStringList parts = line.split( ' ', SplitSkipEmptyParts ); + if ( parts.length() >= 3 && !parts[ 0 ].startsWith( '#' ) ) + { + // Lines have format: ..., so check + // the mountpoint field. Everything starts with an empty string. + if ( parts[ 1 ].startsWith( mountPrefix ) ) + { + l.append( { parts[ 0 ], parts[ 1 ] } ); + } + } + } while ( !f.atEnd() ); + return l; +} + +} // namespace Partition +} // namespace Calamares diff --git a/calamares/src/libcalamares/partition/Mount.h b/calamares/src/libcalamares/partition/Mount.h new file mode 100644 index 0000000..3781c4f --- /dev/null +++ b/calamares/src/libcalamares/partition/Mount.h @@ -0,0 +1,112 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef PARTITION_MOUNT_H +#define PARTITION_MOUNT_H + +#include "DllMacro.h" + +#include +#include +#include + +#include + +namespace Calamares +{ +namespace Partition +{ + +/** + * Runs the mount utility with the specified parameters. + * @param devicePath the path of the partition to mount. + * @param mountPoint the full path of the target mount point. + * @param filesystemName the name of the filesystem (optional). + * @param options any additional options as passed to mount -o (optional). + * If @p options starts with a dash (-) then it is passed unchanged + * and no -o option is added; this is used in handling --bind mounts. + * @returns the program's exit code, or: + * Crashed = QProcess crash + * FailedToStart = QProcess cannot start + * NoWorkingDirectory = bad arguments + */ +DLLEXPORT int mount( const QString& devicePath, + const QString& mountPoint, + const QString& filesystemName = QString(), + const QString& options = QString() ); + +/** @brief Unmount the given @p path (device or mount point). + * + * Runs umount(8) in the host system. + * + * @returns the program's exit code, or special codes like mount(). + */ +DLLEXPORT int unmount( const QString& path, const QStringList& options = QStringList() ); + + +/** @brief Mount and automatically unmount a device + * + * The TemporaryMount object mounts a filesystem, and is like calling + * the mount() function, above. When the object is destroyed, unmount() + * is called with suitable options to undo the original mount. + */ +class DLLEXPORT TemporaryMount +{ +public: + TemporaryMount( const QString& devicePath, + const QString& filesystemName = QString(), + const QString& options = QString() ); + TemporaryMount( const TemporaryMount& ) = delete; + TemporaryMount& operator=( const TemporaryMount& ) = delete; + ~TemporaryMount(); + + bool isValid() const { return bool( m_d ); } + QString path() const; + +private: + struct Private; + std::unique_ptr< Private > m_d; +}; + + +/** @brief Information about a mount point from /etc/mtab + * + * Entries in /etc/mtab are of the form: + * This struct only stores device and mountpoint. + * + * The main way of getting these structs is to call fromMtab() to read + * an /etc/mtab-like file and storing all of the entries from it. + */ +struct DLLEXPORT MtabInfo +{ + QString device; + QString mountPoint; + + /** @brief Reads an mtab-like file and returns the entries from it + * + * When @p mtabPath is given, that file is read. If the given name is + * empty (e.g. the default) then /etc/mtab is read, instead. + * + * If @p mountPrefix is given, then only entries that have a mount point + * that starts with that prefix are returned. + */ + static QList< MtabInfo > fromMtabFilteredByPrefix( const QString& mountPrefix = QString(), + const QString& mtabPath = QString() ); + /// @brief Predicate to sort MtabInfo objects by device-name + static bool deviceOrder( const MtabInfo& a, const MtabInfo& b ) { return a.device > b.device; } + /// @brief Predicate to sort MtabInfo objects by mount-point + static bool mountPointOrder( const MtabInfo& a, const MtabInfo& b ) { return a.mountPoint > b.mountPoint; } +}; + +} // namespace Partition +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/partition/PartitionIterator.cpp b/calamares/src/libcalamares/partition/PartitionIterator.cpp new file mode 100644 index 0000000..610e51b --- /dev/null +++ b/calamares/src/libcalamares/partition/PartitionIterator.cpp @@ -0,0 +1,139 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "PartitionIterator.h" + +// KPMcore +#include +#include +#include + +namespace Calamares +{ +namespace Partition +{ + +using Partition = ::Partition; + +PartitionIterator::PartitionIterator( PartitionTable* table ) + : m_table( table ) +{ +} + +Partition* +PartitionIterator::operator*() const +{ + return m_current; +} + +void +PartitionIterator::operator++() +{ + if ( !m_current ) + { + return; + } + if ( m_current->hasChildren() ) + { + // Go to the first child + m_current = static_cast< Partition* >( m_current->children().first() ); + return; + } + PartitionNode* parent = m_current->parent(); + Partition* successor = parent->successor( *m_current ); + if ( successor ) + { + // Go to the next sibling + m_current = successor; + return; + } + if ( parent->isRoot() ) + { + // We reached the end + m_current = nullptr; + return; + } + // Try to go to the next sibling of our parent + + PartitionNode* grandParent = parent->parent(); + Q_ASSERT( grandParent ); + // If parent is not root, then it's not a PartitionTable but a + // Partition, we can static_cast it. + m_current = grandParent->successor( *static_cast< Partition* >( parent ) ); +} + +bool +PartitionIterator::operator==( const PartitionIterator& other ) const +{ + return m_table == other.m_table && m_current == other.m_current; +} + +bool +PartitionIterator::operator!=( const PartitionIterator& other ) const +{ + return !( *this == other ); +} + +PartitionIterator +PartitionIterator::begin( Device* device ) +{ + if ( !device ) + { + return PartitionIterator( nullptr ); + } + PartitionTable* table = device->partitionTable(); + if ( !table ) + { + return PartitionIterator( nullptr ); + } + return PartitionIterator::begin( table ); +} + +PartitionIterator +PartitionIterator::begin( PartitionTable* table ) +{ + auto it = PartitionIterator( table ); + QList< Partition* > children = table->children(); + // Does not usually happen, but it did happen on a tiny (10MiB) disk with an MBR + // partition table. + if ( children.isEmpty() ) + { + return it; + } + it.m_current = children.first(); + return it; +} + +PartitionIterator +PartitionIterator::end( Device* device ) +{ + if ( !device ) + { + return PartitionIterator( nullptr ); + } + PartitionTable* table = device->partitionTable(); + if ( !table ) + { + return PartitionIterator( nullptr ); + } + + return PartitionIterator::end( table ); +} + +PartitionIterator +PartitionIterator::end( PartitionTable* table ) +{ + return PartitionIterator( table ); +} + +} // namespace Partition +} // namespace Calamares diff --git a/calamares/src/libcalamares/partition/PartitionIterator.h b/calamares/src/libcalamares/partition/PartitionIterator.h new file mode 100644 index 0000000..b447ca0 --- /dev/null +++ b/calamares/src/libcalamares/partition/PartitionIterator.h @@ -0,0 +1,69 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + + +/* + * NOTE: this functionality is only available when Calamares is compiled + * with KPMcore support. + */ + +#ifndef PARTITION_PARTITIONITERATOR_H +#define PARTITION_PARTITIONITERATOR_H + +#include "DllMacro.h" + +class Device; +class Partition; +class PartitionTable; + +namespace Calamares +{ +namespace Partition +{ + +/** @brief Iterator over KPMCore partitions + * + * A forward-only iterator to go through the partitions of a device, + * independently of whether they are primary, logical or extended. + * + * An iterator can be created from a device (then it refers to the + * partition table of that device) or a partition table. The + * partition table must remain valid throughout iteration. + * + * A nullptr is valid, for an empty iterator. + */ +class DLLEXPORT PartitionIterator +{ +public: + ::Partition* operator*() const; + + void operator++(); + + bool operator==( const PartitionIterator& other ) const; + bool operator!=( const PartitionIterator& other ) const; + + static PartitionIterator begin( ::Device* device ); + static PartitionIterator begin( ::PartitionTable* table ); + static PartitionIterator end( ::Device* device ); + static PartitionIterator end( ::PartitionTable* table ); + +private: + PartitionIterator( ::PartitionTable* table ); + + ::PartitionTable* m_table; + ::Partition* m_current = nullptr; +}; + +} // namespace Partition +} // namespace Calamares + +#endif // PARTITION_PARTITIONITERATOR_H diff --git a/calamares/src/libcalamares/partition/PartitionQuery.cpp b/calamares/src/libcalamares/partition/PartitionQuery.cpp new file mode 100644 index 0000000..d1329a5 --- /dev/null +++ b/calamares/src/libcalamares/partition/PartitionQuery.cpp @@ -0,0 +1,115 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "PartitionQuery.h" + +#include "PartitionIterator.h" + +#include +#include +#include + +namespace Calamares +{ +namespace Partition +{ + +// Types from KPMCore +using ::Device; +using ::Partition; + +const PartitionTable* +getPartitionTable( const Partition* partition ) +{ + const PartitionNode* root = partition; + while ( root && !root->isRoot() ) + { + root = root->parent(); + } + + return dynamic_cast< const PartitionTable* >( root ); +} + + +bool +isPartitionFreeSpace( const Partition* partition ) +{ + return partition->roles().has( PartitionRole::Unallocated ); +} + + +bool +isPartitionNew( const Partition* partition ) +{ + return partition->state() == Partition::State::New; +} + + +Partition* +findPartitionByCurrentMountPoint( const QList< Device* >& devices, const QString& mountPoint ) +{ + for ( auto device : devices ) + { + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + if ( ( *it )->mountPoint() == mountPoint ) + { + return *it; + } + } + } + return nullptr; +} + + +Partition* +findPartitionByPath( const QList< Device* >& devices, const QString& path ) +{ + if ( path.simplified().isEmpty() ) + { + return nullptr; + } + + for ( auto device : devices ) + { + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + if ( ( *it )->partitionPath() == path.simplified() ) + { + return *it; + } + } + } + return nullptr; +} + + +QList< Partition* > +findPartitions( const QList< Device* >& devices, std::function< bool( Partition* ) > criterionFunction ) +{ + QList< Partition* > results; + for ( auto device : devices ) + { + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + if ( criterionFunction( *it ) ) + { + results.append( *it ); + } + } + } + return results; +} + + +} // namespace Partition +} // namespace Calamares diff --git a/calamares/src/libcalamares/partition/PartitionQuery.h b/calamares/src/libcalamares/partition/PartitionQuery.h new file mode 100644 index 0000000..24567be --- /dev/null +++ b/calamares/src/libcalamares/partition/PartitionQuery.h @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/* + * NOTE: this functionality is only available when Calamares is compiled + * with KPMcore support. + */ + +#ifndef PARTITION_PARTITIONQUERY_H +#define PARTITION_PARTITIONQUERY_H + +#include "DllMacro.h" + +#include + +#include + +class Device; +class Partition; +class PartitionTable; + +namespace Calamares +{ +namespace Partition +{ + +using ::Device; +using ::Partition; +using ::PartitionTable; + +/** @brief Get partition table */ +DLLEXPORT const PartitionTable* getPartitionTable( const Partition* partition ); + +/** @brief Is this a free-space area? */ +DLLEXPORT bool isPartitionFreeSpace( const Partition* ); + +/** @brief Is this partition newly-to-be-created? + * + * Returns true if the partition is planned to be created by the installer as + * opposed to already existing on the disk. + */ +DLLEXPORT bool isPartitionNew( const Partition* ); + +/** + * Iterates on all devices and return the first partition which is (already) + * mounted on @p mountPoint. + */ +DLLEXPORT Partition* findPartitionByCurrentMountPoint( const QList< Device* >& devices, const QString& mountPoint ); + +// TODO: add this distinction +// Partition* findPartitionByIntendedMountPoint( const QList< Device* >& devices, const QString& mountPoint ); + +/** + * Iterates on all devices and partitions and returns a pointer to the Partition object + * for the given path, or nullptr if a Partition for the given path cannot be found. + */ +DLLEXPORT Partition* findPartitionByPath( const QList< Device* >& devices, const QString& path ); + +/** + * Iterates on all devices and partitions and returns a list of pointers to the Partition + * objects that satisfy the conditions defined in the criterion function. + */ +DLLEXPORT QList< Partition* > findPartitions( const QList< Device* >& devices, + std::function< bool( Partition* ) > criterionFunction ); +} // namespace Partition +} // namespace Calamares + +#endif // PARTITION_PARTITIONQUERY_H diff --git a/calamares/src/libcalamares/partition/PartitionSize.cpp b/calamares/src/libcalamares/partition/PartitionSize.cpp new file mode 100644 index 0000000..184e8b0 --- /dev/null +++ b/calamares/src/libcalamares/partition/PartitionSize.cpp @@ -0,0 +1,291 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "partition/PartitionSize.h" +#include "utils/Logger.h" +#include "utils/Units.h" + +namespace Calamares +{ +namespace Partition +{ + +static const NamedEnumTable< SizeUnit >& +unitSuffixes() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< SizeUnit > names { + { QStringLiteral( "%" ), SizeUnit::Percent }, + { QStringLiteral( "K" ), SizeUnit::KiB }, + { QStringLiteral( "KiB" ), SizeUnit::KiB }, + { QStringLiteral( "M" ), SizeUnit::MiB }, + { QStringLiteral( "MiB" ), SizeUnit::MiB }, + { QStringLiteral( "G" ), SizeUnit::GiB }, + { QStringLiteral( "GiB" ), SizeUnit::GiB }, + { QStringLiteral( "KB" ), SizeUnit::KB }, + { QStringLiteral( "MB" ), SizeUnit::MB }, + { QStringLiteral( "GB" ), SizeUnit::GB } + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +PartitionSize::PartitionSize( const QString& s ) + : NamedSuffix( unitSuffixes(), s ) +{ + if ( ( unit() == SizeUnit::Percent ) && ( value() > 100 || value() < 0 ) ) + { + cDebug() << "Percent value" << value() << "is not valid."; + m_value = 0; + } + + if ( m_unit == SizeUnit::None ) + { + m_value = s.toLongLong(); + if ( m_value > 0 ) + { + m_unit = SizeUnit::Byte; + } + } + + if ( m_value <= 0 ) + { + m_value = 0; + m_unit = SizeUnit::None; + } +} + +qint64 +PartitionSize::toSectors( qint64 totalSectors, qint64 sectorSize ) const +{ + if ( !isValid() ) + { + return -1; + } + if ( totalSectors < 1 || sectorSize < 1 ) + { + return -1; + } + + switch ( m_unit ) + { + case SizeUnit::None: + return -1; + case SizeUnit::Percent: + if ( value() == 100 ) + { + return totalSectors; // Common-case, avoid futzing around + } + else + { + return totalSectors * value() / 100; + } + case SizeUnit::Byte: + case SizeUnit::KB: + case SizeUnit::KiB: + case SizeUnit::MB: + case SizeUnit::MiB: + case SizeUnit::GB: + case SizeUnit::GiB: + return Calamares::bytesToSectors( toBytes(), sectorSize ); + } + + return -1; +} + +qint64 +PartitionSize::toBytes( qint64 totalSectors, qint64 sectorSize ) const +{ + if ( !isValid() ) + { + return -1; + } + + switch ( m_unit ) + { + case SizeUnit::None: + return -1; + case SizeUnit::Percent: + if ( totalSectors < 1 || sectorSize < 1 ) + { + return -1; + } + if ( value() == 100 ) + { + return totalSectors * sectorSize; // Common-case, avoid futzing around + } + else + { + return totalSectors * value() / 100; + } + case SizeUnit::Byte: + case SizeUnit::KB: + case SizeUnit::KiB: + case SizeUnit::MB: + case SizeUnit::MiB: + case SizeUnit::GB: + case SizeUnit::GiB: + return toBytes(); + } + __builtin_unreachable(); +} + +qint64 +PartitionSize::toBytes( qint64 totalBytes ) const +{ + if ( !isValid() ) + { + return -1; + } + + switch ( m_unit ) + { + case SizeUnit::None: + return -1; + case SizeUnit::Percent: + if ( totalBytes < 1 ) + { + return -1; + } + if ( value() == 100 ) + { + return totalBytes; // Common-case, avoid futzing around + } + else + { + return totalBytes * value() / 100; + } + case SizeUnit::Byte: + case SizeUnit::KB: + case SizeUnit::KiB: + case SizeUnit::MB: + case SizeUnit::MiB: + case SizeUnit::GB: + case SizeUnit::GiB: + return toBytes(); + } + __builtin_unreachable(); +} + +qint64 +PartitionSize::toBytes() const +{ + if ( !isValid() ) + { + return -1; + } + + switch ( m_unit ) + { + case SizeUnit::None: + case SizeUnit::Percent: + return -1; + case SizeUnit::Byte: + return value(); + case SizeUnit::KB: + return Calamares::KBtoBytes( static_cast< unsigned long long >( value() ) ); + case SizeUnit::KiB: + return Calamares::KiBtoBytes( static_cast< unsigned long long >( value() ) ); + case SizeUnit::MB: + return Calamares::MBtoBytes( static_cast< unsigned long long >( value() ) ); + case SizeUnit::MiB: + return Calamares::MiBtoBytes( static_cast< unsigned long long >( value() ) ); + case SizeUnit::GB: + return Calamares::GBtoBytes( static_cast< unsigned long long >( value() ) ); + case SizeUnit::GiB: + return Calamares::GiBtoBytes( static_cast< unsigned long long >( value() ) ); + } + __builtin_unreachable(); +} + +bool +PartitionSize::operator<( const PartitionSize& other ) const +{ + if ( !unitsComparable( m_unit, other.m_unit ) ) + { + return false; + } + + switch ( m_unit ) + { + case SizeUnit::None: + return false; + case SizeUnit::Percent: + return ( m_value < other.m_value ); + case SizeUnit::Byte: + case SizeUnit::KB: + case SizeUnit::KiB: + case SizeUnit::MB: + case SizeUnit::MiB: + case SizeUnit::GB: + case SizeUnit::GiB: + return ( toBytes() < other.toBytes() ); + } + __builtin_unreachable(); +} + +bool +PartitionSize::operator>( const PartitionSize& other ) const +{ + if ( !unitsComparable( m_unit, other.m_unit ) ) + { + return false; + } + + switch ( m_unit ) + { + case SizeUnit::None: + return false; + case SizeUnit::Percent: + return ( m_value > other.m_value ); + case SizeUnit::Byte: + case SizeUnit::KB: + case SizeUnit::KiB: + case SizeUnit::MB: + case SizeUnit::MiB: + case SizeUnit::GB: + case SizeUnit::GiB: + return ( toBytes() > other.toBytes() ); + } + __builtin_unreachable(); +} + +bool +PartitionSize::operator==( const PartitionSize& other ) const +{ + if ( !unitsComparable( m_unit, other.m_unit ) ) + { + return false; + } + + switch ( m_unit ) + { + case SizeUnit::None: + return false; + case SizeUnit::Percent: + return ( m_value == other.m_value ); + case SizeUnit::Byte: + case SizeUnit::KB: + case SizeUnit::KiB: + case SizeUnit::MB: + case SizeUnit::MiB: + case SizeUnit::GB: + case SizeUnit::GiB: + return ( toBytes() == other.toBytes() ); + } + __builtin_unreachable(); +} + +} // namespace Partition +} // namespace Calamares diff --git a/calamares/src/libcalamares/partition/PartitionSize.h b/calamares/src/libcalamares/partition/PartitionSize.h new file mode 100644 index 0000000..f7a1aa2 --- /dev/null +++ b/calamares/src/libcalamares/partition/PartitionSize.h @@ -0,0 +1,120 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef PARTITION_PARTITIONSIZE_H +#define PARTITION_PARTITIONSIZE_H + +#include "DllMacro.h" +#include "utils/NamedSuffix.h" +#include "utils/Units.h" + +// Qt +#include + +namespace Calamares +{ +namespace Partition +{ + +enum class SizeUnit +{ + None, + Percent, + Byte, + KB, + KiB, + MB, + MiB, + GB, + GiB +}; + +/** @brief Partition size expressions + * + * Sizes can be specified in bytes, KiB, MiB, GiB or percent (of + * the available drive space are on). This class handles parsing + * of such strings from the config file. + */ +class DLLEXPORT PartitionSize : public NamedSuffix< SizeUnit, SizeUnit::None > +{ +public: + PartitionSize() + : NamedSuffix() + { + } + PartitionSize( int v, SizeUnit u ) + : NamedSuffix( v, u ) + { + } + PartitionSize( const QString& ); + + bool isValid() const { return ( unit() != SizeUnit::None ) && ( value() > 0 ); } + + bool operator<( const PartitionSize& other ) const; + bool operator>( const PartitionSize& other ) const; + bool operator==( const PartitionSize& other ) const; + + /** @brief Convert the size to the number of sectors @p totalSectors . + * + * Each sector has size @p sectorSize, for converting sizes in Bytes, + * KiB, MiB or GiB to sector counts. + * + * @return the number of sectors needed, or -1 for invalid sizes. + */ + qint64 toSectors( qint64 totalSectors, qint64 sectorSize ) const; + + /** @brief Convert the size to bytes. + * + * The device's sectors count @p totalSectors and sector size + * @p sectoreSize are used to calculated the total size, which + * is then used to calculate the size when using Percent. + * + * @return the size in bytes, or -1 for invalid sizes. + */ + qint64 toBytes( qint64 totalSectors, qint64 sectorSize ) const; + + /** @brief Convert the size to bytes. + * + * Total size @p totalBytes is needed for sizes in Percent. This + * parameter is unused in any other case. + * + * @return the size in bytes, or -1 for invalid sizes. + */ + qint64 toBytes( qint64 totalBytes ) const; + + /** @brief Convert the size to bytes. + * + * This method is only valid for sizes in Bytes, KiB, MiB or GiB. + * It will return -1 in any other case. Note that 0KiB and 0MiB and + * 0GiB are considered **invalid** sizes and return -1. + * + * @return the size in bytes, or -1 if it cannot be calculated. + */ + qint64 toBytes() const; + + /** @brief Are the units comparable? + * + * None units cannot be compared with anything. Percentages can + * be compared with each other, and all the explicit sizes (KiB, ...) + * can be compared with each other. + */ + static constexpr bool unitsComparable( const SizeUnit u1, const SizeUnit u2 ) + { + return !( ( u1 == SizeUnit::None || u2 == SizeUnit::None ) + || ( u1 == SizeUnit::Percent && u2 != SizeUnit::Percent ) + || ( u1 != SizeUnit::Percent && u2 == SizeUnit::Percent ) ); + } +}; + +} // namespace Partition +} // namespace Calamares + +#endif // PARTITION_PARTITIONSIZE_H diff --git a/calamares/src/libcalamares/partition/Sync.cpp b/calamares/src/libcalamares/partition/Sync.cpp new file mode 100644 index 0000000..3593f44 --- /dev/null +++ b/calamares/src/libcalamares/partition/Sync.cpp @@ -0,0 +1,35 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Sync.h" + +#include "utils/Logger.h" +#include "utils/System.h" + +void +Calamares::Partition::sync() +{ + /* I would normally use full paths here, e.g. /sbin/udevadm and /bin/sync, + * but there's enough variation / opinion on where these executables + * should live, that full paths would need to be configurable. + * Instead, just run them and assume they're found in PATH; + * either chroot(8) or env(1) is used to run the command, + * and they do suitable lookup. + */ + auto r = Calamares::System::runCommand( { "udevadm", "settle" }, std::chrono::seconds( 10 ) ); + + if ( r.getExitCode() != 0 ) + { + cWarning() << "Could not settle disks."; + r.explainProcess( "udevadm", std::chrono::seconds( 10 ) ); + } + + Calamares::System::runCommand( { "sync" }, std::chrono::seconds( 10 ) ); +} diff --git a/calamares/src/libcalamares/partition/Sync.h b/calamares/src/libcalamares/partition/Sync.h new file mode 100644 index 0000000..c564b94 --- /dev/null +++ b/calamares/src/libcalamares/partition/Sync.h @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef PARTITION_SYNC_H +#define PARTITION_SYNC_H + +#include "DllMacro.h" + +namespace Calamares +{ +namespace Partition +{ + +/** @brief Run "udevadm settle" or other disk-sync mechanism. + * + * Call this after mounting, unmount, toggling swap, or other functions + * that might cause the disk to be "busy" for other disk-modifying + * actions (in particular, KPMcore actions with the sfdisk backend + * are sensitive, and systemd tends to keep disks busy after a change + * for a while). + */ +DLLEXPORT void sync(); + +/** @brief RAII class for calling sync() */ +struct DLLEXPORT Syncer +{ + ~Syncer() { sync(); } +}; + +} // namespace Partition +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/partition/Tests.cpp b/calamares/src/libcalamares/partition/Tests.cpp new file mode 100644 index 0000000..a1ad3b7 --- /dev/null +++ b/calamares/src/libcalamares/partition/Tests.cpp @@ -0,0 +1,227 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Global.h" +#include "PartitionSize.h" + +#include "GlobalStorage.h" +#include "utils/Logger.h" + +#include +#include + +using SizeUnit = Calamares::Partition::SizeUnit; +using PartitionSize = Calamares::Partition::PartitionSize; + +Q_DECLARE_METATYPE( SizeUnit ) + +class PartitionServiceTests : public QObject +{ + Q_OBJECT +public: + PartitionServiceTests(); + ~PartitionServiceTests() override; + +private Q_SLOTS: + void initTestCase(); + + void testUnitComparison_data(); + void testUnitComparison(); + + void testUnitNormalisation_data(); + void testUnitNormalisation(); + + void testFilesystemGS(); +}; + +PartitionServiceTests::PartitionServiceTests() {} + +PartitionServiceTests::~PartitionServiceTests() {} + +void +PartitionServiceTests::initTestCase() +{ +} + +void +PartitionServiceTests::testUnitComparison_data() +{ + QTest::addColumn< SizeUnit >( "u1" ); + QTest::addColumn< SizeUnit >( "u2" ); + QTest::addColumn< bool >( "comparable" ); + + QTest::newRow( "nones" ) << SizeUnit::None << SizeUnit::None << false; + QTest::newRow( "none+%" ) << SizeUnit::None << SizeUnit::Percent << false; + QTest::newRow( "%+none" ) << SizeUnit::Percent << SizeUnit::None << false; + QTest::newRow( "KiB+none" ) << SizeUnit::KiB << SizeUnit::None << false; + QTest::newRow( "none+MiB" ) << SizeUnit::None << SizeUnit::MiB << false; + + QTest::newRow( "KiB+KiB" ) << SizeUnit::KiB << SizeUnit::KiB << true; + QTest::newRow( "KiB+MiB" ) << SizeUnit::KiB << SizeUnit::MiB << true; + QTest::newRow( "KiB+GiB" ) << SizeUnit::KiB << SizeUnit::GiB << true; + QTest::newRow( "MiB+MiB" ) << SizeUnit::MiB << SizeUnit::MiB << true; + QTest::newRow( "MiB+GiB" ) << SizeUnit::MiB << SizeUnit::GiB << true; + QTest::newRow( "GiB+GiB" ) << SizeUnit::GiB << SizeUnit::GiB << true; + + QTest::newRow( "%+None" ) << SizeUnit::Percent << SizeUnit::None << false; + QTest::newRow( "%+%" ) << SizeUnit::Percent << SizeUnit::Percent << true; + QTest::newRow( "%+KiB" ) << SizeUnit::Percent << SizeUnit::KiB << false; +} + + +static bool +original_compare( SizeUnit m_unit, SizeUnit other_m_unit ) +{ + if ( ( m_unit == SizeUnit::None || other_m_unit == SizeUnit::None ) + || ( m_unit == SizeUnit::Percent && other_m_unit != SizeUnit::Percent ) + || ( m_unit != SizeUnit::Percent && other_m_unit == SizeUnit::Percent ) ) + { + return false; + } + return true; +} + +void +PartitionServiceTests::testUnitComparison() +{ + QFETCH( SizeUnit, u1 ); + QFETCH( SizeUnit, u2 ); + QFETCH( bool, comparable ); + + if ( comparable ) + { + QVERIFY( PartitionSize::unitsComparable( u1, u2 ) ); + QVERIFY( PartitionSize::unitsComparable( u2, u1 ) ); + } + else + { + QVERIFY( !PartitionSize::unitsComparable( u1, u2 ) ); + QVERIFY( !PartitionSize::unitsComparable( u2, u1 ) ); + } + + QCOMPARE( original_compare( u1, u2 ), PartitionSize::unitsComparable( u1, u2 ) ); +} + +/* Operator to make the table in testUnitNormalisation_data easier to write */ +constexpr qint64 +operator""_qi( unsigned long long m ) +{ + return qint64( m ); +} + +void +PartitionServiceTests::testUnitNormalisation_data() +{ + QTest::addColumn< SizeUnit >( "u1" ); + QTest::addColumn< int >( "v" ); + QTest::addColumn< qint64 >( "bytes" ); + + QTest::newRow( "none" ) << SizeUnit::None << 16 << -1_qi; + QTest::newRow( "none" ) << SizeUnit::None << 0 << -1_qi; + QTest::newRow( "none" ) << SizeUnit::None << -2 << -1_qi; + + QTest::newRow( "percent" ) << SizeUnit::Percent << 0 << -1_qi; + QTest::newRow( "percent" ) << SizeUnit::Percent << 16 << -1_qi; + QTest::newRow( "percent" ) << SizeUnit::Percent << -2 << -1_qi; + + QTest::newRow( "KiB" ) << SizeUnit::KiB << 0 << -1_qi; + QTest::newRow( "KiB" ) << SizeUnit::KiB << 1 << 1024_qi; + QTest::newRow( "KiB" ) << SizeUnit::KiB << 1000 << 1024000_qi; + QTest::newRow( "KiB" ) << SizeUnit::KiB << 1024 << 1024 * 1024_qi; + QTest::newRow( "KiB" ) << SizeUnit::KiB << -2 << -1_qi; + + QTest::newRow( "MiB" ) << SizeUnit::MiB << 0 << -1_qi; + QTest::newRow( "MiB" ) << SizeUnit::MiB << 1 << 1024 * 1024_qi; + QTest::newRow( "MiB" ) << SizeUnit::MiB << 1000 << 1024 * 1024000_qi; + QTest::newRow( "MiB" ) << SizeUnit::MiB << 1024 << 1024 * 1024 * 1024_qi; + QTest::newRow( "MiB" ) << SizeUnit::MiB << -2 << -1_qi; + + QTest::newRow( "GiB" ) << SizeUnit::GiB << 0 << -1_qi; + QTest::newRow( "GiB" ) << SizeUnit::GiB << 1 << 1024_qi * 1024 * 1024_qi; + // This one overflows 32-bits, which is why we want 64-bits for the whole table + QTest::newRow( "GiB" ) << SizeUnit::GiB << 2 << 2048_qi * 1024 * 1024_qi; +} + +void +PartitionServiceTests::testUnitNormalisation() +{ + QFETCH( SizeUnit, u1 ); + QFETCH( int, v ); + QFETCH( qint64, bytes ); + + QCOMPARE( PartitionSize( v, u1 ).toBytes(), bytes ); +} + +void +PartitionServiceTests::testFilesystemGS() +{ + using Calamares::Partition::isFilesystemUsedGS; + using Calamares::Partition::useFilesystemGS; + + // Some filesystems names, they don't have to be real + const QStringList fsNames { "ext4", "zfs", "berries", "carrot" }; + // Predicate to return whether we consider this FS in use + auto pred = []( const QString& s ) { return !s.startsWith( 'z' ); }; + + // Fill the GS + Calamares::GlobalStorage gs; + for ( const auto& s : fsNames ) + { + useFilesystemGS( &gs, s, pred( s ) ); + } + + QVERIFY( gs.contains( "filesystem_use" ) ); + { + const auto map = gs.value( "filesystem_use" ).toMap(); + QCOMPARE( map.count(), fsNames.count() ); + } + + for ( const auto& s : fsNames ) + { + QCOMPARE( isFilesystemUsedGS( &gs, s ), pred( s ) ); + } + QCOMPARE( isFilesystemUsedGS( &gs, QStringLiteral( "derp" ) ), false ); + QCOMPARE( isFilesystemUsedGS( &gs, QString() ), false ); + // But I can set a value for QString! + useFilesystemGS( &gs, QString(), true ); + QCOMPARE( isFilesystemUsedGS( &gs, QString() ), true ); + // .. and replace it again + useFilesystemGS( &gs, QString(), false ); + QCOMPARE( isFilesystemUsedGS( &gs, QString() ), false ); + // Now there is one more key + { + const auto map = gs.value( "filesystem_use" ).toMap(); + QCOMPARE( map.count(), fsNames.count() + 1 ); + } + + // The API says that it it case-insensitive + QVERIFY( !isFilesystemUsedGS( &gs, "ZFS" ) ); + QVERIFY( isFilesystemUsedGS( &gs, "EXT4" ) ); + QCOMPARE( isFilesystemUsedGS( &gs, "ZFS" ), isFilesystemUsedGS( &gs, "zfs" ) ); + QCOMPARE( isFilesystemUsedGS( &gs, "EXT4" ), isFilesystemUsedGS( &gs, "ext4" ) ); + + useFilesystemGS( &gs, "EXT4", false ); + QVERIFY( !isFilesystemUsedGS( &gs, "EXT4" ) ); + QCOMPARE( isFilesystemUsedGS( &gs, "EXT4" ), isFilesystemUsedGS( &gs, "ext4" ) ); + useFilesystemGS( &gs, "ext4", true ); + QVERIFY( isFilesystemUsedGS( &gs, "EXT4" ) ); + + Calamares::Partition::clearFilesystemGS( &gs ); + QVERIFY( !isFilesystemUsedGS( &gs, "ZFS" ) ); + QVERIFY( !isFilesystemUsedGS( &gs, "EXT4" ) ); + QVERIFY( !isFilesystemUsedGS( &gs, "ext4" ) ); +} + + +QTEST_GUILESS_MAIN( PartitionServiceTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/libcalamares/partition/calautomount.cpp b/calamares/src/libcalamares/partition/calautomount.cpp new file mode 100644 index 0000000..3906f30 --- /dev/null +++ b/calamares/src/libcalamares/partition/calautomount.cpp @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/** @brief Command-line tool to enable or disable automounting + * + * This application uses Calamares methods to enable or disable + * automount settings in the running system. This can be used to + * test the automount-manipulating code without running + * a full Calamares or doing an installation. + * + */ + +static const char usage[] = "Usage: calautomount <-e|-d>\n" + "\n" + "Enables (if `-e` is passed as command-line option) or\n" + "Disables (if `-d` is passed as command-line option)\n" + "\n" + "automounting of disks in the host system as best it can.\n" + "Exits with code 0 on success or 1 if an unknown option is\n" + "passed on the command-line.\n\n"; + +#include "AutoMount.h" +#include "Sync.h" +#include "utils/Logger.h" + +#include +#include + +int +main( int argc, char** argv ) +{ + QCoreApplication app( argc, argv ); + + if ( ( argc != 2 ) || ( argv[ 1 ][ 0 ] != '-' ) || ( argv[ 1 ][ 1 ] != 'e' && argv[ 1 ][ 1 ] != 'd' ) ) + { + qWarning() << usage; + return 1; + } + + Logger::setupLogfile(); + Logger::setupLogLevel( Logger::LOGDEBUG ); + Calamares::Partition::automountDisable( argv[ 1 ][ 1 ] == 'd' ); + + return 0; +} diff --git a/calamares/src/libcalamares/pybind11/Api.cpp b/calamares/src/libcalamares/pybind11/Api.cpp new file mode 100644 index 0000000..61829e3 --- /dev/null +++ b/calamares/src/libcalamares/pybind11/Api.cpp @@ -0,0 +1,322 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020, 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "Api.h" + +#include "Pybind11Helpers.h" +#include "PythonJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" +#include "locale/Global.h" +#include "python/Variant.h" +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/Runner.h" +#include "utils/String.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include +#include +#include + +namespace py = pybind11; + +/** @namespace + * + * Helper functions for converting Python (pybind11) types to Qt types. + */ +namespace +{ + +QVariantList variantListFromPyList( const Calamares::Python::List& list ); +QVariantMap variantMapFromPyDict( const Calamares::Python::Dictionary& dict ); + +QVariant +variantFromPyObject( const py::handle& o ) +{ + if ( py::isinstance< Calamares::Python::Dictionary >( o ) ) + { + return variantMapFromPyDict( py::cast< Calamares::Python::Dictionary >( o ) ); + } + else if ( py::isinstance< Calamares::Python::List >( o ) ) + { + return variantListFromPyList( py::cast< Calamares::Python::List >( o ) ); + } + else if ( py::isinstance< py::int_ >( o ) ) + { + return QVariant( qlonglong( py::cast< py::int_ >( o ) ) ); + } + else if ( py::isinstance< py::float_ >( o ) ) + { + return QVariant( double( py::cast< py::float_ >( o ) ) ); + } + else if ( py::isinstance< py::str >( o ) ) + { + return QVariant( QString::fromStdString( std::string( py::str( o ) ) ) ); + } + else if ( py::isinstance< py::bool_ >( o ) ) + { + return QVariant( bool( py::cast< py::bool_ >( o ) ) ); + } + + return QVariant(); +} + +QVariantList +variantListFromPyList( const Calamares::Python::List& list ) +{ + QVariantList l; + for ( const auto item : list ) + { + l.append( variantFromPyObject( item ) ); + } + return l; +} + +QVariantMap +variantMapFromPyDict( const Calamares::Python::Dictionary& dict ) +{ + QVariantMap m; + for ( const auto item : dict ) + { + m.insert( Calamares::Python::asQString( item.first ), variantFromPyObject( ( item.second ) ) ); + } + return m; +} + +QStringList +stringListFromPyList( const Calamares::Python::List& list ) +{ + QStringList l; + for ( const auto item : list ) + { + l.append( Calamares::Python::asQString( item ) ); + } + return l; +} + +int +raise_on_error( const Calamares::ProcessResult& ec, const QStringList& commandList ) +{ + if ( ec.first == 0 ) + { + return 0; + } + + QString raise = QString( "import subprocess\n" + "e = subprocess.CalledProcessError(%1,\"%2\")\n" ) + .arg( ec.first ) + .arg( commandList.join( ' ' ) ); + if ( !ec.second.isEmpty() ) + { + raise.append( QStringLiteral( "e.output = \"\"\"%1\"\"\"\n" ).arg( ec.second ) ); + } + raise.append( "raise e" ); + py::exec( raise.toStdString() ); + py::error_already_set(); + return ec.first; +} + +int +process_output( Calamares::Utils::RunLocation location, + const QStringList& args, + const Calamares::Python::Object& callback, + const std::string& input, + int timeout ) +{ + Calamares::Utils::Runner r( args ); + r.setLocation( location ); + if ( !callback.is_none() ) + { + if ( py::isinstance< Calamares::Python::List >( callback ) ) + { + QObject::connect( &r, + &decltype( r )::output, + [ list_append = callback.attr( "append" ) ]( const QString& s ) + { list_append( s.toStdString() ); } ); + } + else + { + QObject::connect( + &r, &decltype( r )::output, [ &callback ]( const QString& s ) { callback( s.toStdString() ); } ); + } + r.enableOutputProcessing(); + } + if ( !input.empty() ) + { + r.setInput( QString::fromStdString( input ) ); + } + if ( timeout > 0 ) + { + r.setTimeout( std::chrono::seconds( timeout ) ); + } + + auto result = r.run(); + return raise_on_error( result, args ); +} + +} // namespace + +/** @namespace + * + * This is where the "public Python API" lives. It does not need to + * be a namespace, and it does not need to be public, but it's + * convenient to group things together. + */ +namespace Calamares +{ +namespace Python +{ + +int +target_env_call( const List& args, const std::string& input, int timeout ) +{ + return Calamares::System::instance() + ->targetEnvCommand( + stringListFromPyList( args ), QString(), QString::fromStdString( input ), std::chrono::seconds( timeout ) ) + .first; +} + +int +target_env_call( const std::string& command, const std::string& input, int timeout ) +{ + return Calamares::System::instance() + ->targetEnvCommand( { QString::fromStdString( command ) }, + QString(), + QString::fromStdString( input ), + std::chrono::seconds( timeout ) ) + .first; +} + +int +check_target_env_call( const List& args, const std::string& input, int timeout ) +{ + const auto commandList = stringListFromPyList( args ); + auto ec = Calamares::System::instance()->targetEnvCommand( + commandList, QString(), QString::fromStdString( input ), std::chrono::seconds( timeout ) ); + return raise_on_error( ec, commandList ); +} + +std::string +check_target_env_output( const List& args, const std::string& input, int timeout ) +{ + const auto commandList = stringListFromPyList( args ); + auto ec = Calamares::System::instance()->targetEnvCommand( + commandList, QString(), QString::fromStdString( input ), std::chrono::seconds( timeout ) ); + raise_on_error( ec, commandList ); + return ec.second.toStdString(); +} + +int +target_env_process_output( const List& args, const Object& callback, const std::string& input, int timeout ) +{ + return process_output( + Calamares::System::RunLocation::RunInTarget, stringListFromPyList( args ), callback, input, timeout ); +} +int +host_env_process_output( const List& args, const Object& callback, const std::string& input, int timeout ) +{ + return process_output( + Calamares::System::RunLocation::RunInHost, stringListFromPyList( args ), callback, input, timeout ); +} + +JobProxy::JobProxy( Calamares::Python::Job* parent ) + : prettyName( parent->prettyName().toStdString() ) + , workingPath( parent->workingPath().toStdString() ) + , moduleName( QDir( parent->workingPath() ).dirName().toStdString() ) + , configuration( Calamares::Python::variantMapToPyDict( parent->configuration() ) ) + , m_parent( parent ) +{ +} + +void +JobProxy::setprogress( qreal progress ) +{ + if ( progress >= 0.0 && progress <= 1.0 ) + { + m_parent->emitProgress( progress ); + } +} + + +Calamares::GlobalStorage* GlobalStorageProxy::s_gs_instance = nullptr; + +// The special handling for nullptr is only for the testing +// script for the python bindings, which passes in None; +// normal use will have a GlobalStorage from JobQueue::instance() +// passed in. Testing use will leak the allocated GlobalStorage +// object, but that's OK for testing. +GlobalStorageProxy::GlobalStorageProxy( Calamares::GlobalStorage* gs ) + : m_gs( gs ? gs : s_gs_instance ) +{ + if ( !m_gs ) + { + s_gs_instance = new Calamares::GlobalStorage; + m_gs = s_gs_instance; + } +} + +bool +GlobalStorageProxy::contains( const std::string& key ) const +{ + return m_gs->contains( QString::fromStdString( key ) ); +} + +int +GlobalStorageProxy::count() const +{ + return m_gs->count(); +} + +void +GlobalStorageProxy::insert( const std::string& key, const Object& value ) +{ + m_gs->insert( QString::fromStdString( key ), variantFromPyObject( value ) ); +} + +List +GlobalStorageProxy::keys() const +{ + List pyList; + const auto keys = m_gs->keys(); + for ( const QString& key : keys ) + { + pyList.append( key.toStdString() ); + } + return pyList; +} + +int +GlobalStorageProxy::remove( const std::string& key ) +{ + const QString gsKey( QString::fromStdString( key ) ); + if ( !m_gs->contains( gsKey ) ) + { + cWarning() << "Unknown GS key" << key.c_str(); + } + return m_gs->remove( gsKey ); +} + +Object +GlobalStorageProxy::value( const std::string& key ) const +{ + const QString gsKey( QString::fromStdString( key ) ); + if ( !m_gs->contains( gsKey ) ) + { + cWarning() << "Unknown GS key" << key.c_str(); + return py::none(); + } + return Calamares::Python::variantToPyObject( m_gs->value( gsKey ) ); +} + +} // namespace Python +} // namespace Calamares diff --git a/calamares/src/libcalamares/pybind11/Api.h b/calamares/src/libcalamares/pybind11/Api.h new file mode 100644 index 0000000..27f1db2 --- /dev/null +++ b/calamares/src/libcalamares/pybind11/Api.h @@ -0,0 +1,89 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYBIND11_API_H +#define CALAMARES_PYBIND11_API_H + +/** @file + * + * Contains the API that Python modules use from the Python code + * of that module. This is the C++ side that implements the functions + * imported by the Python code as `import libcalamares`. + */ + +#include "PythonTypes.h" + +#include + +namespace Calamares +{ + +class GlobalStorage; +class PythonJob; + +namespace Python __attribute__( ( visibility( "hidden" ) ) ) +{ + int target_env_call( const List& args, const std::string& input, int timeout ); + int target_env_call( const std::string& command, const std::string& input, int timeout ); + int check_target_env_call( const List& args, const std::string& input, int timeout ); + std::string check_target_env_output( const List& args, const std::string& input, int timeout ); + + int target_env_process_output( const List& args, const Object& callback, const std::string& input, int timeout ); + int host_env_process_output( const List& args, const Object& callback, const std::string& input, int timeout ); + + class Job; + + /** @brief Proxy class in Python for the Calamares Job class + * + * This is available as libcalamares.job in Python code. + */ + class JobProxy + { + public: + explicit JobProxy( Calamares::Python::Job* parent ); + + std::string prettyName; + std::string workingPath; + std::string moduleName; + + Dictionary configuration; + + void setprogress( qreal progress ); + + private: + Calamares::Python::Job* m_parent; + }; + + class GlobalStorageProxy + { + public: + explicit GlobalStorageProxy( Calamares::GlobalStorage* gs ); + + bool contains( const std::string& key ) const; + int count() const; + void insert( const std::string& key, const Object& value ); + List keys() const; + int remove( const std::string& key ); + Object value( const std::string& key ) const; + + // This is a helper for scripts that do not go through + // the JobQueue (i.e. the module testpython script), + // which allocate their own (singleton) GlobalStorage. + static Calamares::GlobalStorage* globalStorageInstance() { return s_gs_instance; } + + private: + Calamares::GlobalStorage* m_gs; + static Calamares::GlobalStorage* s_gs_instance; // See globalStorageInstance() + }; + + +} // namespace Python +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/pybind11/Pybind11Helpers.h b/calamares/src/libcalamares/pybind11/Pybind11Helpers.h new file mode 100644 index 0000000..f9a0116 --- /dev/null +++ b/calamares/src/libcalamares/pybind11/Pybind11Helpers.h @@ -0,0 +1,30 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYBIND11_PYBIND11HELPERS_H +#define CALAMARES_PYBIND11_PYBIND11HELPERS_H + +#include "PythonTypes.h" + +#include + +namespace Calamares +{ +namespace Python __attribute__( ( visibility( "hidden" ) ) ) +{ + inline QString asQString( const pybind11::handle& o ) + { + return QString::fromUtf8( pybind11::str( o ).cast< std::string >().c_str() ); + } + +} // namespace Python +} // namespace Calamares + + +#endif diff --git a/calamares/src/libcalamares/pybind11/PythonJob.cpp b/calamares/src/libcalamares/pybind11/PythonJob.cpp new file mode 100644 index 0000000..69abeb0 --- /dev/null +++ b/calamares/src/libcalamares/pybind11/PythonJob.cpp @@ -0,0 +1,434 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "PythonJob.h" + +#include "CalamaresVersionX.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "pybind11/Api.h" +#include "pybind11/Pybind11Helpers.h" +#include "python/Api.h" +#include "utils/Logger.h" + +#include +#include +#include + +#ifdef WITH_PYBIND11 +#else +#error Source only for pybind11 +#endif + +namespace py = pybind11; + +// Forward-declare function generated by PYBIND11_MODULE +static void pybind11_init_libcalamares( ::pybind11::module_& variable ); + +namespace +{ + +static const char* s_preScript = nullptr; + +QString +getPrettyNameFromScope( const py::dict& scope ) +{ + static constexpr char key_name[] = "pretty_name"; + + if ( scope.contains( key_name ) ) + { + const py::object func = scope[ key_name ]; + try + { + const auto s = func().cast< std::string >(); + return QString::fromUtf8( s.c_str() ); + } + catch ( const py::cast_error& ) + { + // Ignore, we will try __doc__ next + } + } + + static constexpr char key_doc[] = "__doc__"; + if ( scope.contains( key_doc ) ) + { + const py::object doc = scope[ key_doc ]; + try + { + const auto s = doc.cast< std::string >(); + auto string = QString::fromUtf8( s.c_str() ).trimmed(); + const auto newline_index = string.indexOf( '\n' ); + if ( newline_index >= 0 ) + { + string.truncate( newline_index ); + return string; + } + // __doc__ is apparently empty, try next fallback + } + catch ( const py::cast_error& ) + { + // Ignore, try next fallback + } + } + + // No more fallbacks + return QString(); +} + +void +populate_utils( py::module_& m ) +{ + m.def( "obscure", &Calamares::Python::obscure, "A function that obscures (encodes) a string" ); + + m.def( "debug", &Calamares::Python::debug, "Log a debug-message" ); + m.def( "warn", &Calamares::Python::warning, "Log a warning-message" ); + m.def( "warning", &Calamares::Python::warning, "Log a warning-message" ); + m.def( "error", &Calamares::Python::error, "Log an error-message" ); + + m.def( "load_yaml", &Calamares::Python::load_yaml, "Loads YAML from a file." ); + + m.def( "target_env_call", + py::overload_cast(&Calamares::Python::target_env_call), + "Runs command_list in target, returns exit code.", + py::arg( "command_list" ), + py::arg( "input" ) = std::string(), + py::arg( "timeout" ) = 0 ); + m.def( "target_env_call", + py::overload_cast(&Calamares::Python::target_env_call), + "Runs command in target, returns exit code.", + py::arg( "command_list" ), + py::arg( "input" ) = std::string(), + py::arg( "timeout" ) = 0 ); + m.def( "check_target_env_call", + &Calamares::Python::check_target_env_call, + "Runs command in target, raises on error exit.", + py::arg( "command_list" ), + py::arg( "input" ) = std::string(), + py::arg( "timeout" ) = 0 ); + m.def( "check_target_env_output", + &Calamares::Python::check_target_env_output, + "Runs command in target, returns standard output or raises on error.", + py::arg( "command_list" ), + py::arg( "input" ) = std::string(), + py::arg( "timeout" ) = 0 ); + m.def( "target_env_process_output", + &Calamares::Python::target_env_process_output, + "Runs command in target, updating callback and returns standard output or raises on error.", + py::arg( "command_list" ), + py::arg( "callback" ) = pybind11::none(), + py::arg( "input" ) = std::string(), + py::arg( "timeout" ) = 0 ); + m.def( "host_env_process_output", + &Calamares::Python::host_env_process_output, + "Runs command in target, updating callback and returns standard output or raises on error.", + py::arg( "command_list" ), + py::arg( "callback" ) = pybind11::none(), + py::arg( "input" ) = std::string(), + py::arg( "timeout" ) = 0 ); + + m.def( "gettext_languages", + &Calamares::Python::gettext_languages, + "Returns list of languages (most to least-specific) for gettext." ); + m.def( "gettext_path", &Calamares::Python::gettext_path, "Returns path for gettext search." ); + + m.def( "mount", + &Calamares::Python::mount, + "Runs the mount utility with the specified parameters.\n" + "Returns the program's exit code, or:\n" + "-1 = QProcess crash\n" + "-2 = QProcess cannot start\n" + "-3 = bad arguments" ); +} + +void +populate_libcalamares( py::module_& m ) +{ + m.doc() = "Calamares API for Python"; + + m.add_object( "ORGANIZATION_NAME", Calamares::Python::String( CALAMARES_ORGANIZATION_NAME ) ); + m.add_object( "ORGANIZATION_DOMAIN", Calamares::Python::String( CALAMARES_ORGANIZATION_DOMAIN ) ); + m.add_object( "APPLICATION_NAME", Calamares::Python::String( CALAMARES_APPLICATION_NAME ) ); + m.add_object( "VERSION", Calamares::Python::String( CALAMARES_VERSION ) ); + m.add_object( "VERSION_SHORT", Calamares::Python::String( CALAMARES_VERSION_SHORT ) ); + + auto utils = m.def_submodule( "utils", "Calamares Utility API for Python" ); + populate_utils( utils ); + + py::class_< Calamares::Python::JobProxy >( m, "Job" ) + .def_readonly( "module_name", &Calamares::Python::JobProxy::moduleName ) + .def_readonly( "pretty_name", &Calamares::Python::JobProxy::prettyName ) + .def_readonly( "working_path", &Calamares::Python::JobProxy::workingPath ) + .def_readonly( "configuration", &Calamares::Python::JobProxy::configuration ) + .def( "setprogress", &Calamares::Python::JobProxy::setprogress ); + + py::class_< Calamares::Python::GlobalStorageProxy >( m, "GlobalStorage" ) + .def( py::init( []( std::nullptr_t ) { return new Calamares::Python::GlobalStorageProxy( nullptr ); } ) ) + .def( "contains", &Calamares::Python::GlobalStorageProxy::contains ) + .def( "count", &Calamares::Python::GlobalStorageProxy::count ) + .def( "insert", &Calamares::Python::GlobalStorageProxy::insert ) + .def( "keys", &Calamares::Python::GlobalStorageProxy::keys ) + .def( "remove", &Calamares::Python::GlobalStorageProxy::remove ) + .def( "value", &Calamares::Python::GlobalStorageProxy::value ); +} + +} // namespace + +namespace Calamares +{ +namespace Python +{ + +struct Job::Private +{ + Private( const QString& script, const QString& path, const QVariantMap& configuration ) + : scriptFile( script ) + , workingPath( path ) + , configurationMap( configuration ) + { + } + QString scriptFile; // From the module descriptor + QString workingPath; + + QVariantMap configurationMap; // The module configuration + + QString description; // Obtained from the Python code +}; + +Job::Job( const QString& scriptFile, + const QString& workingPath, + const QVariantMap& moduleConfiguration, + QObject* parent ) + : ::Calamares::Job( parent ) + , m_d( std::make_unique< Job::Private >( scriptFile, workingPath, moduleConfiguration ) ) +{ +} + +Job::~Job() {} + +QString +Job::prettyName() const +{ + return QDir( m_d->workingPath ).dirName(); +} + +QString +Job::prettyStatusMessage() const +{ + // The description is updated when progress is reported, see emitProgress() + if ( m_d->description.isEmpty() ) + { + return tr( "Running %1 operation." ).arg( prettyName() ); + } + else + { + return m_d->description; + } +} + +JobResult +Job::exec() +{ + // We assume m_scriptFile to be relative to m_workingPath. + QDir workingDir( m_d->workingPath ); + if ( !workingDir.exists() || !workingDir.isReadable() ) + { + return JobResult::error( tr( "Bad working directory path" ), + tr( "Working directory %1 for python job %2 is not readable." ) + .arg( m_d->workingPath ) + .arg( prettyName() ) ); + } + + QFileInfo scriptFI( workingDir.absoluteFilePath( m_d->scriptFile ) ); + if ( !scriptFI.exists() || !scriptFI.isFile() || !scriptFI.isReadable() ) + { + return JobResult::error( tr( "Bad main script file" ), + tr( "Main script file %1 for python job %2 is not readable." ) + .arg( scriptFI.absoluteFilePath() ) + .arg( prettyName() ) ); + } + + py::scoped_interpreter guard {}; + // Import, but do not keep the handle lying around + try + { + // import() only works if the library can be found through + // normal Python import mechanisms -- and after installation, + // libcalamares can not be found. An alternative, like using + // PYBIND11_EMBEDDED_MODULE, falls foul of not being able + // to `import libcalamares` from external Python scripts, + // which are used in tests. + // + // auto calamaresModule = py::module_::import( "libcalamares" ); + // + // Using the constructor directly generates compiler warnings + // because this is deprecated. + // + // auto calamaresModule = py::module_("libcalamares"); + // + // So create it by hand, using code cribbed from pybind11/embed.h + // to register an extension module. This does not make it + // available to the current interpreter. + // + static ::pybind11::module_::module_def libcalamares_def; + auto calamaresModule = py::module_::create_extension_module( "libcalamares", nullptr, &libcalamares_def ); + pybind11_init_libcalamares( calamaresModule ); + + // Add libcalamares to the main namespace (as if it has already + // been imported) and also to sys.modules under its own name. + // Now `import libcalamares` in modules will find the already- + // loaded module. + auto scope = py::module_::import( "__main__" ).attr( "__dict__" ); + scope[ "libcalamares" ] = calamaresModule; + + auto sys = scope[ "sys" ].attr( "modules" ); + sys[ "libcalamares" ] = calamaresModule; + + calamaresModule.attr( "job" ) = Calamares::Python::JobProxy( this ); + calamaresModule.attr( "globalstorage" ) + = Calamares::Python::GlobalStorageProxy( JobQueue::instance()->globalStorage() ); + } + catch ( const py::error_already_set& e ) + { + cError() << "Error in import:" << e.what(); + throw; // This is non-recoverable + } + + if ( s_preScript ) + { + try + { + py::exec( s_preScript ); + } + catch ( const py::error_already_set& e ) + { + cError() << "Error in pre-script:" << e.what(); + return JobResult::internalError( + tr( "Bad internal script" ), + tr( "Internal script for python job %1 raised an exception." ).arg( prettyName() ), + JobResult::PythonUncaughtException ); + } + } + + try + { + py::eval_file( scriptFI.absoluteFilePath().toUtf8().constData() ); + } + catch ( const py::error_already_set& e ) + { + cError() << "Error while loading:" << e.what(); + return JobResult::internalError( + tr( "Bad main script file" ), + tr( "Main script file %1 for python job %2 could not be loaded because it raised an exception." ) + .arg( scriptFI.absoluteFilePath() ) + .arg( prettyName() ), + JobResult::PythonUncaughtException ); + } + + auto scope = py::module_::import( "__main__" ).attr( "__dict__" ); + m_d->description = getPrettyNameFromScope( scope ); + + Q_EMIT progress( 0 ); + static constexpr char key_run[] = "run"; + if ( scope.contains( key_run ) ) + { + const py::object run = scope[ key_run ]; + try + { + py::object r; + try + { + r = run(); + } + catch ( const py::error_already_set& e ) + { + // This is an error in the Python code itself + cError() << "Error while running:" << e.what(); + return JobResult::internalError( tr( "Bad main script file" ), + tr( "Main script file %1 for python job %2 raised an exception." ) + .arg( scriptFI.absoluteFilePath() ) + .arg( prettyName() ), + JobResult::PythonUncaughtException ); + } + + if ( r.is( py::none() ) ) + { + return JobResult::ok(); + } + const py::tuple items = r; + return JobResult::error( asQString( items[ 0 ] ), asQString( items[ 1 ] ) ); + } + catch ( const py::cast_error& e ) + { + cError() << "Error in type of run() or its results:" << e.what(); + return JobResult::error( tr( "Bad main script file" ), + tr( "Main script file %1 for python job %2 returned invalid results." ) + .arg( scriptFI.absoluteFilePath() ) + .arg( prettyName() ) ); + } + catch ( const py::error_already_set& e ) + { + cError() << "Error in return type of run():" << e.what(); + return JobResult::error( tr( "Bad main script file" ), + tr( "Main script file %1 for python job %2 returned invalid results." ) + .arg( scriptFI.absoluteFilePath() ) + .arg( prettyName() ) ); + } + } + else + { + return JobResult::error( tr( "Bad main script file" ), + tr( "Main script file %1 for python job %2 does not contain a run() function." ) + .arg( scriptFI.absoluteFilePath() ) + .arg( prettyName() ) ); + } +} + +QString +Job::workingPath() const +{ + return m_d->workingPath; +} +QVariantMap +Job::configuration() const +{ + return m_d->configurationMap; +} + +void +Job::emitProgress( double progressValue ) +{ + // TODO: update prettyname + emit progress( progressValue ); +} + +/** @brief Sets the pre-run Python code for all PythonJobs + * + * A PythonJob runs the code from the scriptFile parameter to + * the constructor; the pre-run code is **also** run, before + * even the scriptFile code. Use this in testing mode + * to modify Python internals. + * + * No ownership of @p script is taken: pass in a pointer to + * a character literal or something that lives longer than the + * job. Pass in @c nullptr to switch off pre-run code. + */ +void +Job::setInjectedPreScript( const char* script ) +{ + s_preScript = script; + cDebug() << "Python pre-script set to string" << Logger::Pointer( script ) << "length" + << ( script ? strlen( script ) : 0 ); +} + +} // namespace Python +} // namespace Calamares + +PYBIND11_MODULE( libcalamares, m ) +{ + populate_libcalamares( m ); +} diff --git a/calamares/src/libcalamares/pybind11/PythonJob.h b/calamares/src/libcalamares/pybind11/PythonJob.h new file mode 100644 index 0000000..197b7a3 --- /dev/null +++ b/calamares/src/libcalamares/pybind11/PythonJob.h @@ -0,0 +1,73 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYBIND11_PYTHONJOB_H +#define CALAMARES_PYBIND11_PYTHONJOB_H + +// This file is called PythonJob.h because it would otherwise +// clash with the Job.h in libcalamares proper. + +#include "CalamaresConfig.h" +#include "DllMacro.h" +#include "Job.h" + +#include + +#include + +#ifdef WITH_PYBIND11 +#else +#error Source only for pybind11 +#endif + +namespace Calamares +{ +namespace Python +{ +class Job : public ::Calamares::Job +{ + Q_OBJECT +public: + explicit DLLEXPORT Job( const QString& scriptFile, + const QString& workingPath, + const QVariantMap& moduleConfiguration = QVariantMap(), + QObject* parent = nullptr ); + ~Job() override; + + QString prettyName() const override; + QString prettyStatusMessage() const override; + ::Calamares::JobResult exec() override; + + /** @brief Sets the pre-run Python code for all PythonJobs + * + * A PythonJob runs the code from the scriptFile parameter to + * the constructor; the pre-run code is **also** run, before + * even the scriptFile code. Use this in testing mode + * to modify Python internals. + * + * No ownership of @p script is taken: pass in a pointer to + * a character literal or something that lives longer than the + * job. Pass in @c nullptr to switch off pre-run code. + */ + static DLLEXPORT void setInjectedPreScript( const char* script ); + + /** @brief Accessors for JobProxy */ + QString workingPath() const; + QVariantMap configuration() const; + /** @brief Proxy functions */ + void emitProgress( double progressValue ); + +private: + struct Private; + std::unique_ptr< Private > m_d; +}; + +} // namespace Python +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/pybind11/PythonTypes.h b/calamares/src/libcalamares/pybind11/PythonTypes.h new file mode 100644 index 0000000..ac7a245 --- /dev/null +++ b/calamares/src/libcalamares/pybind11/PythonTypes.h @@ -0,0 +1,56 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023, 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYBIND11_PYTHONTYPES_H +#define CALAMARES_PYBIND11_PYTHONTYPES_H + +#include + +QT_WARNING_PUSH +QT_WARNING_DISABLE_CLANG( "-Wcovered-switch-default" ) +QT_WARNING_DISABLE_CLANG( "-Wfloat-equal" ) +QT_WARNING_DISABLE_CLANG( "-Wweak-vtables" ) +QT_WARNING_DISABLE_CLANG( "-Wmissing-variable-declarations" ) +QT_WARNING_DISABLE_CLANG( "-Wold-style-cast" ) +QT_WARNING_DISABLE_CLANG( "-Wshadow-uncaptured-local" ) +QT_WARNING_DISABLE_CLANG( "-Wshadow-field-in-constructor" ) +QT_WARNING_DISABLE_CLANG( "-Wshadow-field" ) +QT_WARNING_DISABLE_CLANG( "-Wdocumentation" ) +QT_WARNING_DISABLE_CLANG( "-Wmissing-noreturn" ) +QT_WARNING_DISABLE_CLANG( "-Wreserved-identifier" ) + +#undef slots +#include + +#include +#include + +QT_WARNING_POP + +namespace Calamares +{ +namespace Python __attribute__( ( visibility( "hidden" ) ) ) +{ + using Dictionary = pybind11::dict; + using List = pybind11::list; + using Object = pybind11::object; + + inline auto None() + { + return pybind11::none(); + } + + using Integer = pybind11::int_; + using Float = pybind11::float_; + using Boolean = pybind11::bool_; + using String = pybind11::str; +} // namespace Python +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/pyboost/PythonHelper.cpp b/calamares/src/libcalamares/pyboost/PythonHelper.cpp new file mode 100644 index 0000000..cc9df45 --- /dev/null +++ b/calamares/src/libcalamares/pyboost/PythonHelper.cpp @@ -0,0 +1,374 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PythonHelper.h" + +#include "GlobalStorage.h" +#include "compat/Variant.h" +#include "python/Variant.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" + +#include +#include + +namespace bp = boost::python; + +namespace CalamaresPython +{ + +QVariant +variantFromPyObject( const boost::python::object& pyObject ) +{ + std::string pyType = bp::extract< std::string >( pyObject.attr( "__class__" ).attr( "__name__" ) ); + if ( pyType == "dict" ) + { + return variantMapFromPyDict( bp::extract< bp::dict >( pyObject ) ); + } + + else if ( pyType == "list" ) + { + return variantListFromPyList( bp::extract< bp::list >( pyObject ) ); + } + + else if ( pyType == "int" ) + { + return QVariant( bp::extract< int >( pyObject ) ); + } + + else if ( pyType == "float" ) + { + return QVariant( bp::extract< double >( pyObject ) ); + } + + else if ( pyType == "str" ) + { + return QVariant( QString::fromStdString( bp::extract< std::string >( pyObject ) ) ); + } + + else if ( pyType == "bool" ) + { + return QVariant( bp::extract< bool >( pyObject ) ); + } + + else + { + return QVariant(); + } +} + +QVariantList +variantListFromPyList( const boost::python::list& pyList ) +{ + QVariantList list; + for ( int i = 0; i < bp::len( pyList ); ++i ) + { + list.append( variantFromPyObject( pyList[ i ] ) ); + } + return list; +} + +QVariantMap +variantMapFromPyDict( const boost::python::dict& pyDict ) +{ + QVariantMap map; + bp::list keys = pyDict.keys(); + for ( int i = 0; i < bp::len( keys ); ++i ) + { + bp::extract< std::string > extracted_key( keys[ i ] ); + if ( !extracted_key.check() ) + { + cDebug() << "Key invalid, map might be incomplete."; + continue; + } + + std::string key = extracted_key; + + bp::object obj = pyDict[ key ]; + + map.insert( QString::fromStdString( key ), variantFromPyObject( obj ) ); + } + return map; +} + +QVariantHash +variantHashFromPyDict( const boost::python::dict& pyDict ) +{ + QVariantHash hash; + bp::list keys = pyDict.keys(); + for ( int i = 0; i < bp::len( keys ); ++i ) + { + bp::extract< std::string > extracted_key( keys[ i ] ); + if ( !extracted_key.check() ) + { + cDebug() << "Key invalid, map might be incomplete."; + continue; + } + + std::string key = extracted_key; + + bp::object obj = pyDict[ key ]; + + hash.insert( QString::fromStdString( key ), variantFromPyObject( obj ) ); + } + return hash; +} + +static inline void +add_if_lib_exists( const QDir& dir, const char* name, QStringList& list ) +{ + if ( !( dir.exists() && dir.isReadable() ) ) + { + return; + } + + QFileInfo fi( dir.absoluteFilePath( name ) ); + if ( fi.exists() && fi.isReadable() ) + { + list.append( fi.dir().absolutePath() ); + } +} + +Helper::Helper() + : QObject( nullptr ) +{ + // Let's make extra sure we only call Py_Initialize once + if ( !Py_IsInitialized() ) + { + Py_Initialize(); + } + + m_mainModule = bp::import( "__main__" ); + m_mainNamespace = m_mainModule.attr( "__dict__" ); + + // If we're running from the build dir + add_if_lib_exists( QDir::current(), "libcalamares.so", m_pythonPaths ); + + QDir calaPythonPath( Calamares::systemLibDir().absolutePath() + QDir::separator() + "calamares" ); + add_if_lib_exists( calaPythonPath, "libcalamares.so", m_pythonPaths ); + + bp::object sys = bp::import( "sys" ); + + foreach ( QString path, m_pythonPaths ) + { + bp::str dir = path.toLocal8Bit().data(); + sys.attr( "path" ).attr( "append" )( dir ); + } +} + +Helper::~Helper() {} + +Helper* +Helper::instance() +{ + static Helper* s_helper = nullptr; + + if ( !s_helper ) + { + s_helper = new Helper; + } + return s_helper; +} + +boost::python::dict +Helper::createCleanNamespace() +{ + // To make sure we run each script with a clean namespace, we only fetch the + // builtin namespace from the interpreter as it was when freshly initialized. + bp::dict scriptNamespace; + scriptNamespace[ "__builtins__" ] = m_mainNamespace[ "__builtins__" ]; + + return scriptNamespace; +} + +QString +Helper::handleLastError() +{ + PyObject *type = nullptr, *val = nullptr, *traceback_p = nullptr; + PyErr_Fetch( &type, &val, &traceback_p ); + + Logger::CDebug debug; + debug.noquote() << "Python Error:\n"; + + QString typeMsg; + if ( type != nullptr ) + { + bp::handle<> h_type( type ); + bp::str pystr( h_type ); + bp::extract< std::string > extracted( pystr ); + if ( extracted.check() ) + { + typeMsg = QString::fromStdString( extracted() ).trimmed(); + } + + if ( typeMsg.isEmpty() ) + { + typeMsg = tr( "Unknown exception type", "@error" ); + } + debug << typeMsg << '\n'; + } + + QString valMsg; + if ( val != nullptr ) + { + bp::handle<> h_val( val ); + bp::str pystr( h_val ); + bp::extract< std::string > extracted( pystr ); + if ( extracted.check() ) + { + valMsg = QString::fromStdString( extracted() ).trimmed(); + } + + if ( valMsg.isEmpty() ) + { + valMsg = tr( "Unparseable Python error", "@error" ); + } + + // Special-case: CalledProcessError has an attribute "output" with the command output, + // add that to the printed message. + if ( typeMsg.contains( "CalledProcessError" ) ) + { + bp::object exceptionObject( h_val ); + auto a = exceptionObject.attr( "output" ); + bp::str outputString( a ); + bp::extract< std::string > extractedOutput( outputString ); + + QString output; + if ( extractedOutput.check() ) + { + output = QString::fromStdString( extractedOutput() ).trimmed(); + } + if ( !output.isEmpty() ) + { + // Replace the Type of the error by the warning string, + // and use the output of the command (e.g. its stderr) as value. + typeMsg = valMsg; + valMsg = output; + } + } + debug << valMsg << '\n'; + } + + QString tbMsg; + if ( traceback_p != nullptr ) + { + bp::handle<> h_tb( traceback_p ); + bp::object traceback_module( bp::import( "traceback" ) ); + bp::object format_tb( traceback_module.attr( "format_tb" ) ); + bp::object tb_list( format_tb( h_tb ) ); + bp::object pystr( bp::str( "\n" ).join( tb_list ) ); + bp::extract< std::string > extracted( pystr ); + if ( extracted.check() ) + { + tbMsg = QString::fromStdString( extracted() ).trimmed(); + } + + if ( tbMsg.isEmpty() ) + { + tbMsg = tr( "Unparseable Python traceback", "@error" ); + } + debug << tbMsg << '\n'; + } + + if ( typeMsg.isEmpty() && valMsg.isEmpty() && tbMsg.isEmpty() ) + { + return tr( "Unfetchable Python error", "@error" ); + } + + QStringList msgList; + if ( !typeMsg.isEmpty() ) + { + msgList.append( QString( "%1" ).arg( typeMsg.toHtmlEscaped() ) ); + } + if ( !valMsg.isEmpty() ) + { + msgList.append( valMsg.toHtmlEscaped() ); + } + + if ( !tbMsg.isEmpty() ) + { + msgList.append( QStringLiteral( "
Traceback:" ) ); + msgList.append( QString( "
%1
" ).arg( tbMsg.toHtmlEscaped() ) ); + } + + // Return a string made of the msgList items, wrapped in
tags + return QString( "
%1
" ).arg( msgList.join( "
" ) ); +} + +Calamares::GlobalStorage* GlobalStoragePythonWrapper::s_gs_instance = nullptr; + +// The special handling for nullptr is only for the testing +// script for the python bindings, which passes in None; +// normal use will have a GlobalStorage from JobQueue::instance() +// passed in. Testing use will leak the allocated GlobalStorage +// object, but that's OK for testing. +GlobalStoragePythonWrapper::GlobalStoragePythonWrapper( Calamares::GlobalStorage* gs ) + : m_gs( gs ? gs : s_gs_instance ) +{ + if ( !m_gs ) + { + s_gs_instance = new Calamares::GlobalStorage; + m_gs = s_gs_instance; + } +} + +bool +GlobalStoragePythonWrapper::contains( const std::string& key ) const +{ + return m_gs->contains( QString::fromStdString( key ) ); +} + +int +GlobalStoragePythonWrapper::count() const +{ + return m_gs->count(); +} + +void +GlobalStoragePythonWrapper::insert( const std::string& key, const bp::object& value ) +{ + m_gs->insert( QString::fromStdString( key ), CalamaresPython::variantFromPyObject( value ) ); +} + +bp::list +GlobalStoragePythonWrapper::keys() const +{ + bp::list pyList; + const auto keys = m_gs->keys(); + for ( const QString& key : keys ) + { + pyList.append( key.toStdString() ); + } + return pyList; +} + +int +GlobalStoragePythonWrapper::remove( const std::string& key ) +{ + const QString gsKey( QString::fromStdString( key ) ); + if ( !m_gs->contains( gsKey ) ) + { + cWarning() << "Unknown GS key" << key.c_str(); + } + return m_gs->remove( gsKey ); +} + +bp::object +GlobalStoragePythonWrapper::value( const std::string& key ) const +{ + const QString gsKey( QString::fromStdString( key ) ); + if ( !m_gs->contains( gsKey ) ) + { + cWarning() << "Unknown GS key" << key.c_str(); + } + return Calamares::Python::variantToPyObject( m_gs->value( gsKey ) ); +} + +} // namespace CalamaresPython diff --git a/calamares/src/libcalamares/pyboost/PythonHelper.h b/calamares/src/libcalamares/pyboost/PythonHelper.h new file mode 100644 index 0000000..e3b27e6 --- /dev/null +++ b/calamares/src/libcalamares/pyboost/PythonHelper.h @@ -0,0 +1,78 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYBOOST_PYTHONHELPER_H +#define CALAMARES_PYBOOST_PYTHONHELPER_H + +#include "DllMacro.h" +#include "PythonJob.h" +#include "PythonTypes.h" + +#include + +namespace Calamares +{ +class GlobalStorage; +} // namespace Calamares + +namespace CalamaresPython +{ + +DLLEXPORT QVariant variantFromPyObject( const boost::python::object& pyObject ); +DLLEXPORT QVariantList variantListFromPyList( const boost::python::list& pyList ); +DLLEXPORT QVariantMap variantMapFromPyDict( const boost::python::dict& pyDict ); +DLLEXPORT QVariantHash variantHashFromPyDict( const boost::python::dict& pyDict ); + + +class DLLEXPORT Helper : public QObject +{ + Q_OBJECT +public: + boost::python::dict createCleanNamespace(); + + QString handleLastError(); + + static Helper* instance(); + +private: + ~Helper() override; + explicit Helper(); + + boost::python::object m_mainModule; + boost::python::object m_mainNamespace; + + QStringList m_pythonPaths; +}; + +class GlobalStoragePythonWrapper +{ +public: + explicit GlobalStoragePythonWrapper( Calamares::GlobalStorage* gs ); + + bool contains( const std::string& key ) const; + int count() const; + void insert( const std::string& key, const boost::python::api::object& value ); + boost::python::list keys() const; + int remove( const std::string& key ); + boost::python::api::object value( const std::string& key ) const; + + // This is a helper for scripts that do not go through + // the JobQueue (i.e. the module testpython script), + // which allocate their own (singleton) GlobalStorage. + static Calamares::GlobalStorage* globalStorageInstance() { return s_gs_instance; } + +private: + Calamares::GlobalStorage* m_gs; + static Calamares::GlobalStorage* s_gs_instance; // See globalStorageInstance() +}; + +} // namespace CalamaresPython + +#endif // CALAMARES_PYTHONJOBHELPER_H diff --git a/calamares/src/libcalamares/pyboost/PythonJob.cpp b/calamares/src/libcalamares/pyboost/PythonJob.cpp new file mode 100644 index 0000000..c3051cd --- /dev/null +++ b/calamares/src/libcalamares/pyboost/PythonJob.cpp @@ -0,0 +1,394 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "PythonJob.h" + +#include "PythonHelper.h" +#include "PythonJobApi.h" +#include "PythonTypes.h" + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "python/Api.h" +#include "utils/Logger.h" + +#include + +#ifdef WITH_PYBIND11 +#error Source only for Boost::Python +#else +#endif + +static const char* s_preScript = nullptr; + +namespace bp = boost::python; + +QT_WARNING_PUSH +QT_WARNING_DISABLE_CLANG( "-Wdisabled-macro-expansion" ) + +BOOST_PYTHON_FUNCTION_OVERLOADS( mount_overloads, Calamares::Python::mount, 2, 4 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( target_env_call_str_overloads, CalamaresPython::target_env_call, 1, 3 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( target_env_call_list_overloads, CalamaresPython::target_env_call, 1, 3 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( check_target_env_call_str_overloads, CalamaresPython::check_target_env_call, 1, 3 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( check_target_env_call_list_overloads, CalamaresPython::check_target_env_call, 1, 3 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( check_target_env_output_str_overloads, + CalamaresPython::check_target_env_output, + 1, + 3 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( check_target_env_output_list_overloads, + CalamaresPython::check_target_env_output, + 1, + 3 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( target_env_process_output_overloads, + CalamaresPython::target_env_process_output, + 1, + 4 ); +BOOST_PYTHON_FUNCTION_OVERLOADS( host_env_process_output_overloads, CalamaresPython::host_env_process_output, 1, 4 ); + +QT_WARNING_POP + +BOOST_PYTHON_MODULE( libcalamares ) +{ + bp::object package = bp::scope(); + package.attr( "__path__" ) = "libcalamares"; + + bp::scope().attr( "ORGANIZATION_NAME" ) = CALAMARES_ORGANIZATION_NAME; + bp::scope().attr( "ORGANIZATION_DOMAIN" ) = CALAMARES_ORGANIZATION_DOMAIN; + bp::scope().attr( "APPLICATION_NAME" ) = CALAMARES_APPLICATION_NAME; + bp::scope().attr( "VERSION" ) = CALAMARES_VERSION; + bp::scope().attr( "VERSION_SHORT" ) = CALAMARES_VERSION_SHORT; + + bp::class_< CalamaresPython::PythonJobInterface >( "Job", bp::init< Calamares::PythonJob* >() ) + .def_readonly( "module_name", &CalamaresPython::PythonJobInterface::moduleName ) + .def_readonly( "pretty_name", &CalamaresPython::PythonJobInterface::prettyName ) + .def_readonly( "working_path", &CalamaresPython::PythonJobInterface::workingPath ) + .def_readonly( "configuration", &CalamaresPython::PythonJobInterface::configuration ) + .def( "setprogress", + &CalamaresPython::PythonJobInterface::setprogress, + bp::args( "progress" ), + "Reports the progress status of this job to Calamares, " + "as a real number between 0 and 1." ); + + bp::class_< CalamaresPython::GlobalStoragePythonWrapper >( "GlobalStorage", + bp::init< Calamares::GlobalStorage* >() ) + .def( "contains", &CalamaresPython::GlobalStoragePythonWrapper::contains ) + .def( "count", &CalamaresPython::GlobalStoragePythonWrapper::count ) + .def( "insert", &CalamaresPython::GlobalStoragePythonWrapper::insert ) + .def( "keys", &CalamaresPython::GlobalStoragePythonWrapper::keys ) + .def( "remove", &CalamaresPython::GlobalStoragePythonWrapper::remove ) + .def( "value", &CalamaresPython::GlobalStoragePythonWrapper::value ); + + // libcalamares.utils submodule starts here + bp::object utilsModule( bp::handle<>( bp::borrowed( PyImport_AddModule( "libcalamares.utils" ) ) ) ); + bp::scope().attr( "utils" ) = utilsModule; + bp::scope utilsScope = utilsModule; + Q_UNUSED( utilsScope ) + + // .. Logging functions + bp::def( + "debug", &Calamares::Python::debug, bp::args( "s" ), "Writes the given string to the Calamares debug stream." ); + bp::def( "warning", + &Calamares::Python::warning, + bp::args( "s" ), + "Writes the given string to the Calamares warning stream." ); + bp::def( "warn", + &Calamares::Python::warning, + bp::args( "s" ), + "Writes the given string to the Calamares warning stream." ); + bp::def( + "error", &Calamares::Python::error, bp::args( "s" ), "Writes the given string to the Calamares error stream." ); + + + // .. YAML functions + bp::def( "load_yaml", &Calamares::Python::load_yaml, bp::args( "path" ), "Loads YAML from a file." ); + + // .. Filesystem functions + bp::def( "mount", + &Calamares::Python::mount, + mount_overloads( bp::args( "device_path", "mount_point", "filesystem_name", "options" ), + "Runs the mount utility with the specified parameters.\n" + "Returns the program's exit code, or:\n" + "-1 = QProcess crash\n" + "-2 = QProcess cannot start\n" + "-3 = bad arguments" ) ); + + // .. Process functions + bp::def( + "target_env_call", + static_cast< int ( * )( const std::string&, const std::string&, int ) >( &CalamaresPython::target_env_call ), + target_env_call_str_overloads( bp::args( "command", "stdin", "timeout" ), + "Runs the specified command in the chroot of the target system.\n" + "Returns the program's exit code, or:\n" + "-1 = QProcess crash\n" + "-2 = QProcess cannot start\n" + "-3 = bad arguments\n" + "-4 = QProcess timeout" ) ); + bp::def( "target_env_call", + static_cast< int ( * )( const bp::list&, const std::string&, int ) >( &CalamaresPython::target_env_call ), + target_env_call_list_overloads( bp::args( "command_list", "stdin", "timeout" ), + "Runs the specified command_list in the chroot of the target system.\n" + "Returns the program's exit code, or:\n" + "-1 = QProcess crash\n" + "-2 = QProcess cannot start\n" + "-3 = bad arguments\n" + "-4 = QProcess timeout" ) ); + + bp::def( "check_target_env_call", + static_cast< int ( * )( const std::string&, const std::string&, int ) >( + &CalamaresPython::check_target_env_call ), + check_target_env_call_str_overloads( bp::args( "command", "stdin", "timeout" ), + "Runs the specified command in the chroot of the target system.\n" + "Returns 0, which is program's exit code if the program exited " + "successfully, or raises a subprocess.CalledProcessError." ) ); + bp::def( + "check_target_env_call", + static_cast< int ( * )( const bp::list&, const std::string&, int ) >( &CalamaresPython::check_target_env_call ), + check_target_env_call_list_overloads( bp::args( "args", "stdin", "timeout" ), + "Runs the specified command in the chroot of the target system.\n" + "Returns 0, which is program's exit code if the program exited " + "successfully, or raises a subprocess.CalledProcessError." ) ); + + bp::def( "check_target_env_output", + static_cast< std::string ( * )( const std::string&, const std::string&, int ) >( + &CalamaresPython::check_target_env_output ), + check_target_env_output_str_overloads( bp::args( "command", "stdin", "timeout" ), + "Runs the specified command in the chroot of the target system.\n" + "Returns the program's standard output, and raises a " + "subprocess.CalledProcessError if something went wrong." ) ); + bp::def( "check_target_env_output", + static_cast< std::string ( * )( const bp::list&, const std::string&, int ) >( + &CalamaresPython::check_target_env_output ), + check_target_env_output_list_overloads( bp::args( "args", "stdin", "timeout" ), + "Runs the specified command in the chroot of the target system.\n" + "Returns the program's standard output, and raises a " + "subprocess.CalledProcessError if something went wrong." ) ); + bp::def( "target_env_process_output", + &CalamaresPython::target_env_process_output, + target_env_process_output_overloads( bp::args( "command", "callback", "stdin", "timeout" ), + "Runs the specified @p command in the target system." ) ); + bp::def( "host_env_process_output", + &CalamaresPython::host_env_process_output, + host_env_process_output_overloads( bp::args( "command", "callback", "stdin", "timeout" ), + "Runs the specified command in the host system." ) ); + + // .. String functions + bp::def( "obscure", + &Calamares::Python::obscure, + bp::args( "s" ), + "Simple string obfuscation function based on KStringHandler::obscure.\n" + "Returns a string, generated using a simple symmetric encryption.\n" + "Applying the function to a string obscured by this function will result " + "in the original string." ); + + // .. Translation functions + bp::def( "gettext_languages", + &Calamares::Python::gettext_languages, + "Returns list of languages (most to least-specific) for gettext." ); + + bp::def( "gettext_path", &Calamares::Python::gettext_path, "Returns path for gettext search." ); +} + + +namespace Calamares +{ + +struct PythonJob::Private +{ + bp::object m_prettyStatusMessage; +}; + +PythonJob::PythonJob( const QString& scriptFile, + const QString& workingPath, + const QVariantMap& moduleConfiguration, + QObject* parent ) + : Job( parent ) + , m_d( std::make_unique< Private >() ) + , m_scriptFile( scriptFile ) + , m_workingPath( workingPath ) + , m_configurationMap( moduleConfiguration ) +{ +} + + +PythonJob::~PythonJob() {} + +QString +PythonJob::prettyName() const +{ + return QDir( m_workingPath ).dirName(); +} + + +QString +PythonJob::prettyStatusMessage() const +{ + // The description is updated when progress is reported, see emitProgress() + const auto s = getDescription(); + if ( s.isEmpty() ) + { + return tr( "Running %1 operation…", "@status" ).arg( QDir( m_workingPath ).dirName() ); + } + else + { + return s; + } +} + +static QString +pythonStringMethod( bp::dict& script, const char* funcName ) +{ + bp::object func = script.get( funcName, bp::object() ); + if ( !func.is_none() ) + { + bp::extract< std::string > result( func() ); + return result.check() ? QString::fromStdString( result() ).trimmed() : QString(); + } + return QString(); +} + + +JobResult +PythonJob::exec() +{ + // We assume m_scriptFile to be relative to m_workingPath. + QDir workingDir( m_workingPath ); + if ( !workingDir.exists() || !workingDir.isReadable() ) + { + return JobResult::error( tr( "Bad working directory path", "@error" ), + tr( "Working directory %1 for python job %2 is not readable.", "@error" ) + .arg( m_workingPath ) + .arg( prettyName() ) ); + } + + QFileInfo scriptFI( workingDir.absoluteFilePath( m_scriptFile ) ); + if ( !scriptFI.exists() || !scriptFI.isFile() || !scriptFI.isReadable() ) + { + return JobResult::error( tr( "Bad main script file", "@error" ), + tr( "Main script file %1 for python job %2 is not readable.", "@error" ) + .arg( scriptFI.absoluteFilePath() ) + .arg( prettyName() ) ); + } + + try + { + bp::dict scriptNamespace = CalamaresPython::Helper::instance()->createCleanNamespace(); + + bp::object calamaresModule = bp::import( "libcalamares" ); + bp::dict calamaresNamespace = bp::extract< bp::dict >( calamaresModule.attr( "__dict__" ) ); + + calamaresNamespace[ "job" ] = CalamaresPython::PythonJobInterface( this ); + calamaresNamespace[ "globalstorage" ] + = CalamaresPython::GlobalStoragePythonWrapper( JobQueue::instance()->globalStorage() ); + + if ( s_preScript ) + { + bp::exec( s_preScript, scriptNamespace, scriptNamespace ); + } + + cDebug() << "Job file" << scriptFI.absoluteFilePath(); + bp::object execResult + = bp::exec_file( scriptFI.absoluteFilePath().toLocal8Bit().data(), scriptNamespace, scriptNamespace ); + bp::object entryPoint = scriptNamespace[ "run" ]; + + m_d->m_prettyStatusMessage = scriptNamespace.get( "pretty_status_message", bp::object() ); + QString possibleDescription = pythonStringMethod( scriptNamespace, "pretty_name" ); + if ( possibleDescription.isEmpty() ) + { + bp::extract< std::string > entryPoint_doc_attr( entryPoint.attr( "__doc__" ) ); + + if ( entryPoint_doc_attr.check() ) + { + possibleDescription= QString::fromStdString( entryPoint_doc_attr() ).trimmed(); + auto i_newline = possibleDescription.indexOf( '\n' ); + if ( i_newline > 0 ) + { + possibleDescription.truncate( i_newline ); + } + cDebug() << Logger::SubEntry << "Job description from __doc__" << prettyName() << '=' << possibleDescription; + } + } + else + { + cDebug() << Logger::SubEntry << "Job description from pretty_name" << prettyName() << '=' << possibleDescription; + } + setDescription( possibleDescription); + emit progress( 0 ); + + bp::object runResult = entryPoint(); + + if ( runResult.is_none() ) + { + return JobResult::ok(); + } + else // Something happened in the Python job + { + bp::tuple resultTuple = bp::extract< bp::tuple >( runResult ); + QString message = QString::fromStdString( bp::extract< std::string >( resultTuple[ 0 ] ) ); + QString description = QString::fromStdString( bp::extract< std::string >( resultTuple[ 1 ] ) ); + return JobResult::error( message, description ); + } + } + catch ( bp::error_already_set& ) + { + QString msg; + if ( PyErr_Occurred() ) + { + msg = CalamaresPython::Helper::instance()->handleLastError(); + } + bp::handle_exception(); + PyErr_Clear(); + return JobResult::internalError( tr( "Boost.Python error in job \"%1\"", "@error" ).arg( prettyName() ), + msg, + JobResult::PythonUncaughtException ); + } +} + + +void +PythonJob::emitProgress( qreal progressValue ) +{ + // This is called from the JobApi (and only from there) from the Job thread, + // so it is safe to call into the Python interpreter. Update the description + // as needed (don't call this from prettyStatusMessage(), which can be + // called from other threads as well). + if ( m_d && !m_d->m_prettyStatusMessage.is_none() ) + { + QString r; + bp::extract< std::string > result( m_d->m_prettyStatusMessage() ); + r = result.check() ? QString::fromStdString( result() ).trimmed() : QString(); + if ( !r.isEmpty() ) + { + setDescription(r); + } + } + emit progress( progressValue ); +} + +void +PythonJob::setInjectedPreScript( const char* preScript ) +{ + s_preScript = preScript; + cDebug() << "Python pre-script set to string" << Logger::Pointer( preScript ) << "length" + << ( preScript ? strlen( preScript ) : 0 ); +} + +QString PythonJob::getDescription() const +{ + QMutexLocker l(&m_descriptionMutex); + return m_description; +} + +void PythonJob::setDescription(const QString & s) +{ + QMutexLocker l(&m_descriptionMutex); + m_description = s; +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/pyboost/PythonJob.h b/calamares/src/libcalamares/pyboost/PythonJob.h new file mode 100644 index 0000000..6a7a4f5 --- /dev/null +++ b/calamares/src/libcalamares/pyboost/PythonJob.h @@ -0,0 +1,84 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYBOOST_PYTHONJOB_H +#define CALAMARES_PYBOOST_PYTHONJOB_H + +#include "DllMacro.h" +#include "Job.h" +#include "modulesystem/InstanceKey.h" + +#include +#include + +#include + +#ifdef WITH_PYBIND11 +#error Source only for Boost::Python +#else +#endif + +namespace CalamaresPython +{ +class PythonJobInterface; +class Helper; +} // namespace CalamaresPython + +namespace Calamares +{ + +class DLLEXPORT PythonJob : public Job +{ + Q_OBJECT +public: + explicit PythonJob( const QString& scriptFile, + const QString& workingPath, + const QVariantMap& moduleConfiguration = QVariantMap(), + QObject* parent = nullptr ); + ~PythonJob() override; + + QString prettyName() const override; + QString prettyStatusMessage() const override; + JobResult exec() override; + + /** @brief Sets the pre-run Python code for all PythonJobs + * + * A PythonJob runs the code from the scriptFile parameter to + * the constructor; the pre-run code is **also** run, before + * even the scriptFile code. Use this in testing mode + * to modify Python internals. + * + * No ownership of @p script is taken: pass in a pointer to + * a character literal or something that lives longer than the + * job. Pass in @c nullptr to switch off pre-run code. + */ + static void setInjectedPreScript( const char* script ); + +private: + struct Private; + + friend class CalamaresPython::PythonJobInterface; + void emitProgress( double progressValue ); + + std::unique_ptr< Private > m_d; + QString m_scriptFile; + QString m_workingPath; + QVariantMap m_configurationMap; + + mutable QMutex m_descriptionMutex; // Guards access to m_description, because that is read and written from multiple threads + QString m_description; + + QString getDescription() const; + void setDescription(const QString & s); +}; + +} // namespace Calamares + +#endif // CALAMARES_PYTHONJOB_H diff --git a/calamares/src/libcalamares/pyboost/PythonJobApi.cpp b/calamares/src/libcalamares/pyboost/PythonJobApi.cpp new file mode 100644 index 0000000..64fe7dd --- /dev/null +++ b/calamares/src/libcalamares/pyboost/PythonJobApi.cpp @@ -0,0 +1,207 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PythonJobApi.h" + +#include "PythonHelper.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "locale/Global.h" +#include "python/Api.h" +#include "python/Variant.h" +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/Runner.h" +#include "utils/String.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include +#include +#include + +namespace bp = boost::python; + +static int +handle_check_target_env_call_error( const Calamares::ProcessResult& ec, const QString& cmd ) +{ + if ( !ec.first ) + { + return ec.first; + } + + QString raise = QString( "import subprocess\n" + "e = subprocess.CalledProcessError(%1,\"%2\")\n" ) + .arg( ec.first ) + .arg( cmd ); + if ( !ec.second.isEmpty() ) + { + raise.append( QStringLiteral( "e.output = \"\"\"%1\"\"\"\n" ).arg( ec.second ) ); + } + raise.append( "raise e" ); + bp::exec( raise.toStdString().c_str() ); + bp::throw_error_already_set(); + return ec.first; +} + +static inline QStringList +bp_list_to_qstringlist( const bp::list& args ) +{ + QStringList list; + for ( int i = 0; i < bp::len( args ); ++i ) + { + list.append( QString::fromStdString( bp::extract< std::string >( args[ i ] ) ) ); + } + return list; +} + +static inline Calamares::ProcessResult +target_env_command( const QStringList& args, const std::string& input, int timeout ) +{ + // Since Python doesn't give us the type system for distinguishing + // seconds from other integral types, massage to seconds here. + return Calamares::System::instance()->targetEnvCommand( + args, QString(), QString::fromStdString( input ), std::chrono::seconds( timeout ) ); +} + +namespace CalamaresPython +{ + +int +target_env_call( const std::string& command, const std::string& input, int timeout ) +{ + return target_env_command( QStringList { QString::fromStdString( command ) }, input, timeout ).first; +} + +int +target_env_call( const bp::list& args, const std::string& input, int timeout ) +{ + return target_env_command( bp_list_to_qstringlist( args ), input, timeout ).first; +} + +int +check_target_env_call( const std::string& command, const std::string& input, int timeout ) +{ + auto ec = target_env_command( QStringList { QString::fromStdString( command ) }, input, timeout ); + return handle_check_target_env_call_error( ec, QString::fromStdString( command ) ); +} + +int +check_target_env_call( const bp::list& args, const std::string& input, int timeout ) +{ + auto ec = target_env_command( bp_list_to_qstringlist( args ), input, timeout ); + if ( !ec.first ) + { + return ec.first; + } + + QStringList failedCmdList = bp_list_to_qstringlist( args ); + return handle_check_target_env_call_error( ec, failedCmdList.join( ' ' ) ); +} + +std::string +check_target_env_output( const std::string& command, const std::string& input, int timeout ) +{ + auto ec = target_env_command( QStringList { QString::fromStdString( command ) }, input, timeout ); + handle_check_target_env_call_error( ec, QString::fromStdString( command ) ); + return ec.second.toStdString(); +} + +std::string +check_target_env_output( const bp::list& args, const std::string& input, int timeout ) +{ + QStringList list = bp_list_to_qstringlist( args ); + auto ec = target_env_command( list, input, timeout ); + handle_check_target_env_call_error( ec, list.join( ' ' ) ); + return ec.second.toStdString(); +} + +PythonJobInterface::PythonJobInterface( Calamares::PythonJob* parent ) + : m_parent( parent ) +{ + auto moduleDir = QDir( m_parent->m_workingPath ); + moduleName = moduleDir.dirName().toStdString(); + prettyName = m_parent->prettyName().toStdString(); + workingPath = m_parent->m_workingPath.toStdString(); + configuration = Calamares::Python::variantMapToPyDict( m_parent->m_configurationMap ); +} + +void +PythonJobInterface::setprogress( qreal progress ) +{ + if ( progress >= 0.0 && progress <= 1.0 ) + { + m_parent->emitProgress( progress ); + } +} + +static inline int +_process_output( Calamares::Utils::RunLocation location, + const boost::python::list& args, + const boost::python::object& callback, + const std::string& input, + int timeout ) +{ + Calamares::Utils::Runner r( bp_list_to_qstringlist( args ) ); + r.setLocation( location ); + if ( !callback.is_none() ) + { + bp::extract< bp::list > x( callback ); + if ( x.check() ) + { + QObject::connect( &r, + &decltype( r )::output, + [ cb = callback.attr( "append" ) ]( const QString& s ) { cb( s.toStdString() ); } ); + } + else + { + QObject::connect( + &r, &decltype( r )::output, [ &callback ]( const QString& s ) { callback( s.toStdString() ); } ); + } + r.enableOutputProcessing(); + } + if ( !input.empty() ) + { + r.setInput( QString::fromStdString( input ) ); + } + if ( timeout > 0 ) + { + r.setTimeout( std::chrono::seconds( timeout ) ); + } + + auto result = r.run(); + + if ( result.getExitCode() ) + { + return handle_check_target_env_call_error( result, r.executable() ); + } + return 0; +} + +int +target_env_process_output( const boost::python::list& args, + const boost::python::object& callback, + const std::string& input, + int timeout ) +{ + return _process_output( Calamares::Utils::RunLocation::RunInTarget, args, callback, input, timeout ); +} + +int +host_env_process_output( const boost::python::list& args, + const boost::python::object& callback, + const std::string& input, + int timeout ) +{ + return _process_output( Calamares::Utils::RunLocation::RunInHost, args, callback, input, timeout ); +} + +} // namespace CalamaresPython diff --git a/calamares/src/libcalamares/pyboost/PythonJobApi.h b/calamares/src/libcalamares/pyboost/PythonJobApi.h new file mode 100644 index 0000000..d7f9e51 --- /dev/null +++ b/calamares/src/libcalamares/pyboost/PythonJobApi.h @@ -0,0 +1,71 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYBOOST_PYTHONJOBAPI_H +#define CALAMARES_PYBOOST_PYTHONJOBAPI_H + +#include "PythonTypes.h" + +#include // For qreal + +#include + +namespace Calamares +{ +class PythonJob; +} // namespace Calamares + +namespace CalamaresPython +{ + +int target_env_call( const std::string& command, const std::string& input = std::string(), int timeout = 0 ); + +int target_env_call( const boost::python::list& args, const std::string& input = std::string(), int timeout = 0 ); + +int check_target_env_call( const std::string& command, const std::string& input = std::string(), int timeout = 0 ); + +int check_target_env_call( const boost::python::list& args, const std::string& input = std::string(), int timeout = 0 ); + +std::string +check_target_env_output( const std::string& command, const std::string& input = std::string(), int timeout = 0 ); + +std::string +check_target_env_output( const boost::python::list& args, const std::string& input = std::string(), int timeout = 0 ); + +int target_env_process_output( const boost::python::list& args, + const boost::python::object& callback = boost::python::object(), + const std::string& input = std::string(), + int timeout = 0 ); + +int host_env_process_output( const boost::python::list& args, + const boost::python::object& callback = boost::python::object(), + const std::string& input = std::string(), + int timeout = 0 ); + +class PythonJobInterface +{ +public: + explicit PythonJobInterface( Calamares::PythonJob* parent ); + + std::string moduleName; + std::string prettyName; + std::string workingPath; + + boost::python::dict configuration; + + void setprogress( qreal progress ); + +private: + Calamares::PythonJob* m_parent; +}; + +} // namespace CalamaresPython + +#endif // PYTHONJOBAPI_H diff --git a/calamares/src/libcalamares/pyboost/PythonTypes.h b/calamares/src/libcalamares/pyboost/PythonTypes.h new file mode 100644 index 0000000..d745255 --- /dev/null +++ b/calamares/src/libcalamares/pyboost/PythonTypes.h @@ -0,0 +1,82 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020, 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* + * The Python and Boost::Python headers are not C++14 warning-proof, especially + * with picky compilers like Clang 8 and 9. Since we use Clang for the + * find-all-the-warnings case, switch those warnings off for + * the we-can't-change-them system headers. + * + * This convenience header handles including all the bits we need for + * Python support, while silencing warnings. + */ +#ifndef CALAMARES_PYBOOST_PYTHONTYPES_H +#define CALAMARES_PYBOOST_PYTHONTYPES_H + +#include + +QT_WARNING_PUSH +QT_WARNING_DISABLE_CLANG( "-Wreserved-id-macro" ) +QT_WARNING_DISABLE_CLANG( "-Wold-style-cast" ) +QT_WARNING_DISABLE_CLANG( "-Wzero-as-null-pointer-constant" ) +QT_WARNING_DISABLE_CLANG( "-Wextra-semi-stmt" ) +QT_WARNING_DISABLE_CLANG( "-Wall" ) +QT_WARNING_DISABLE_CLANG( "-Wimplicit-float-conversion" ) +QT_WARNING_DISABLE_CLANG( "-Wundef" ) +QT_WARNING_DISABLE_CLANG( "-Wdeprecated-dynamic-exception-spec" ) +QT_WARNING_DISABLE_CLANG( "-Wshadow-field-in-constructor" ) +QT_WARNING_DISABLE_CLANG( "-Wshadow" ) +QT_WARNING_DISABLE_CLANG( "-Wmissing-noreturn" ) +QT_WARNING_DISABLE_CLANG( "-Wcast-qual" ) +QT_WARNING_DISABLE_CLANG( "-Wcast-align" ) +QT_WARNING_DISABLE_CLANG( "-Wsign-conversion" ) +QT_WARNING_DISABLE_CLANG( "-Wdouble-promotion" ) +QT_WARNING_DISABLE_CLANG( "-Wredundant-parens" ) +QT_WARNING_DISABLE_CLANG( "-Wweak-vtables" ) +QT_WARNING_DISABLE_CLANG( "-Wdeprecated" ) +QT_WARNING_DISABLE_CLANG( "-Wmissing-field-initializers" ) +QT_WARNING_DISABLE_CLANG( "-Wdisabled-macro-expansion" ) +QT_WARNING_DISABLE_CLANG( "-Wdocumentation" ) +QT_WARNING_DISABLE_CLANG( "-Wcomma" ) +QT_WARNING_DISABLE_CLANG( "-Wunused-parameter" ) +QT_WARNING_DISABLE_CLANG( "-Wunused-template" ) + +// Actually for Python headers +QT_WARNING_DISABLE_CLANG( "-Wreserved-id-macro" ) + +#undef slots +#include +#include +#include +#include +#include + +QT_WARNING_POP + +namespace Calamares +{ +namespace Python __attribute__( ( visibility( "hidden" ) ) ) +{ + using Dictionary = boost::python::dict; + using List = boost::python::list; + using Object = boost::python::object; + + inline auto None() + { + return Object(); + } + + using Integer = Object; + using Float = Object; + using Boolean = Object; + using String = Object; +} // namespace Python +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/python/Api.cpp b/calamares/src/libcalamares/python/Api.cpp new file mode 100644 index 0000000..d7c25e4 --- /dev/null +++ b/calamares/src/libcalamares/python/Api.cpp @@ -0,0 +1,207 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020, 2023-2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "Api.h" + +#include "Variant.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "locale/Global.h" +#include "partition/Mount.h" +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/String.h" +#include "utils/Yaml.h" + +#include +#include +#include + +namespace +{ + +///@brief Prefix added to Python log-messages +constexpr char output_prefix[] = "[PYTHON JOB]:"; + +///@brief Helper function to log a message (adds prefix, wrangles types) +inline void +log_action( unsigned int level, const std::string& s ) +{ + Logger::CDebug( level ) << output_prefix << QString::fromStdString( s ); +} + +Calamares::GlobalStorage* +own_global_storage() +{ + static Calamares::GlobalStorage* p = new Calamares::GlobalStorage; + return p; +} + +QStringList +languages_from_global_storage() +{ + QStringList languages; + + // There are two ways that Python jobs can be initialised: + // - through JobQueue, in which case that has an instance which holds + // a GlobalStorage object, or + // - through the Python test-script, which initialises its + // own GlobalStorageProxy, which then holds a + // GlobalStorage object for all of Python. + Calamares::JobQueue* jq = Calamares::JobQueue::instance(); + Calamares::GlobalStorage* gs = jq ? jq->globalStorage() : own_global_storage(); + + QString lang = Calamares::Locale::readGS( *gs, QStringLiteral( "LANG" ) ); + if ( !lang.isEmpty() ) + { + languages.append( lang ); + if ( lang.indexOf( '.' ) > 0 ) + { + lang.truncate( lang.indexOf( '.' ) ); + languages.append( lang ); + } + if ( lang.indexOf( '_' ) > 0 ) + { + lang.truncate( lang.indexOf( '_' ) ); + languages.append( lang ); + } + } + return languages; +} + +void +append_language_directory( QStringList& pathList, const QString& candidate ) +{ + if ( !candidate.isEmpty() && !pathList.contains( candidate ) ) + { + pathList.prepend( candidate ); + if ( QDir( candidate ).cd( "lang" ) ) + { + pathList.prepend( candidate + "/lang" ); + } + } +} + +} + +namespace Calamares +{ +namespace Python +{ + +std::string +obscure( const std::string& string ) +{ + return Calamares::String::obscure( QString::fromStdString( string ) ).toStdString(); +} + +void +debug( const std::string& s ) +{ + log_action( Logger::LOGDEBUG, s ); +} + +void +warning( const std::string& s ) +{ + log_action( Logger::LOGWARNING, s ); +} + +void +error( const std::string& s ) +{ + log_action( Logger::LOGERROR, s ); +} + +Dictionary +load_yaml( const std::string& path ) +{ + const QString filePath = QString::fromUtf8( path.c_str() ); + bool ok = false; + auto map = Calamares::YAML::load( filePath, &ok ); + if ( !ok ) + { + cWarning() << "Loading YAML from" << filePath << "failed."; + } + + return Calamares::Python::variantMapToPyDict( map ); +} + +Python::List +gettext_languages() +{ + Python::List pyList; + for ( const auto & lang : languages_from_global_storage() ) + { + pyList.append( lang.toStdString() ); + } + return pyList; +} + +Python::Object +gettext_path() +{ + // Going to log informatively just once + static bool first_time = true; + cScopedAssignment( &first_time, false ); + + // TODO: distinguish between -d runs and normal runs + // TODO: can we detect DESTDIR-installs? + QStringList candidatePaths + = QStandardPaths::locateAll( QStandardPaths::GenericDataLocation, "locale", QStandardPaths::LocateDirectory ); + QString extra = QCoreApplication::applicationDirPath(); + append_language_directory( candidatePaths, extra ); // Often /usr/local/bin + if ( !extra.isEmpty() ) + { + QDir d( extra ); + if ( d.cd( "../share/locale" ) ) // Often /usr/local/bin/../share/locale -> /usr/local/share/locale + { + append_language_directory( candidatePaths, d.canonicalPath() ); + } + } + append_language_directory( candidatePaths, QDir().canonicalPath() ); // Current directory, e.g. "." + + if ( first_time ) + { + cDebug() << "Determining gettext path from" << candidatePaths; + } + + QStringList candidateLanguages = languages_from_global_storage(); + for ( const auto& lang : candidateLanguages ) + { + for ( const auto & localedir : candidatePaths ) + { + QDir ldir( localedir ); + if ( ldir.cd( lang ) ) + { + Logger::CDebug( Logger::LOGDEBUG ) + << output_prefix << "Found gettext" << lang << "in" << ldir.canonicalPath(); + return String( localedir.toStdString() ); + } + } + } + cWarning() << "No translation found for languages" << candidateLanguages; + return Python::None(); +} + +int +mount( const std::string& device_path, + const std::string& mount_point, + const std::string& filesystem_name, + const std::string& options ) +{ + return Calamares::Partition::mount( QString::fromStdString( device_path ), + QString::fromStdString( mount_point ), + QString::fromStdString( filesystem_name ), + QString::fromStdString( options ) ); +} + +} +} diff --git a/calamares/src/libcalamares/python/Api.h b/calamares/src/libcalamares/python/Api.h new file mode 100644 index 0000000..00f3321 --- /dev/null +++ b/calamares/src/libcalamares/python/Api.h @@ -0,0 +1,48 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023, 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYTHON_API_H +#define CALAMARES_PYTHON_API_H + +/** @file + * + * Contains some of the API that Python modules use from the Python code + * of that module. The functions declared here have no complications + * regarding the (Python) types being used. + */ + +#include "PythonTypes.h" + +#include + +namespace Calamares +{ +namespace Python __attribute__( ( visibility( "hidden" ) ) ) +{ + std::string obscure( const std::string& string ); + + void debug( const std::string& s ); + void warning( const std::string& s ); + // void warn( const std::string& s) is an alias of warning() defined at the Python level + void error( const std::string& s ); + + Dictionary load_yaml( const std::string& path ); + + List gettext_languages(); + Object gettext_path(); + + int mount( const std::string& device_path, + const std::string& mount_point, + const std::string& filesystem_name = std::string(), + const std::string& options = std::string() ); + +} +} + +#endif diff --git a/calamares/src/libcalamares/python/Variant.cpp b/calamares/src/libcalamares/python/Variant.cpp new file mode 100644 index 0000000..22bf51c --- /dev/null +++ b/calamares/src/libcalamares/python/Variant.cpp @@ -0,0 +1,114 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2020, 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Variant.h" + +#include "PythonTypes.h" +#include "compat/Variant.h" + +namespace +{ + +Calamares::Python::List +variantListToPyList( const QVariantList& variantList ) +{ + Calamares::Python::List pyList; + for ( const QVariant& variant : variantList ) + { + pyList.append( Calamares::Python::variantToPyObject( variant ) ); + } + return pyList; +} + +Calamares::Python::Dictionary +variantHashToPyDict( const QVariantHash& variantHash ) +{ + Calamares::Python::Dictionary pyDict; + for ( auto it = variantHash.constBegin(); it != variantHash.constEnd(); ++it ) + { + pyDict[ Calamares::Python::String( it.key().toStdString() ) ] = Calamares::Python::variantToPyObject( it.value() ); + } + return pyDict; +} + +} + +namespace Calamares +{ +namespace Python +{ + +Dictionary +variantMapToPyDict( const QVariantMap& variantMap ) +{ + Calamares::Python::Dictionary pyDict; + for ( auto it = variantMap.constBegin(); it != variantMap.constEnd(); ++it ) + { + pyDict[ Calamares::Python::String( it.key().toStdString() ) ] = Calamares::Python::variantToPyObject( it.value() ); + } + return pyDict; +} + +Object +variantToPyObject( const QVariant& variant ) +{ + QT_WARNING_PUSH + QT_WARNING_DISABLE_CLANG( "-Wswitch-enum" ) + + // 49 enumeration values not handled + switch ( Calamares::typeOf( variant ) ) + { + case Calamares::MapVariantType: + return variantMapToPyDict( variant.toMap() ); + + case Calamares::HashVariantType: + return variantHashToPyDict( variant.toHash() ); + + case Calamares::ListVariantType: + case Calamares::StringListVariantType: + return variantListToPyList( variant.toList() ); + + case Calamares::IntVariantType: + return Python::Integer( variant.toInt() ); + case Calamares::UIntVariantType: + return Python::Integer( variant.toUInt() ); + + case Calamares::LongLongVariantType: + return Python::Integer( variant.toLongLong() ); + case Calamares::ULongLongVariantType: + return Python::Integer( variant.toULongLong() ); + + case Calamares::DoubleVariantType: + return Python::Float( variant.toDouble() ); + + case Calamares::CharVariantType: +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) +#else + // In Qt6, QChar is also available and different from CharVariantType + case QMetaType::Type::QChar: +#endif + case Calamares::StringVariantType: + return Calamares::Python::String( variant.toString().toStdString() ); + + case Calamares::BoolVariantType: + return Python::Boolean( variant.toBool() ); + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + case QVariant::Invalid: +#endif + default: + return Python::None(); + } + QT_WARNING_POP +} + + + } +} diff --git a/calamares/src/libcalamares/python/Variant.h b/calamares/src/libcalamares/python/Variant.h new file mode 100644 index 0000000..88a5ea6 --- /dev/null +++ b/calamares/src/libcalamares/python/Variant.h @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2020, 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYTHONVARIANT_H +#define CALAMARES_PYTHONVARIANT_H + +/** + * @file Support for turning QVariant into Python types, and vice-versa + * + * These are helper-functions for converting variants (e.g. values + * in GlobalStorage, or loaded from YAML) into Python. This is not + * public API and is used only inside the Python-job-support code. + */ + +#include "PythonTypes.h" + +#include +#include +#include + + +namespace Calamares +{ +namespace Python __attribute__( ( visibility( "hidden" ) ) ) +{ + +Dictionary variantMapToPyDict( const QVariantMap& variantMap ); +Object variantToPyObject( const QVariant& variant ); ///< More generic version of variantMapToPyDict + +} +} + +#endif diff --git a/calamares/src/libcalamares/testdata/localetest_nl.ts b/calamares/src/libcalamares/testdata/localetest_nl.ts new file mode 100644 index 0000000..65a3a28 --- /dev/null +++ b/calamares/src/libcalamares/testdata/localetest_nl.ts @@ -0,0 +1,15 @@ + + + + + + LocaleTests + + + Quit + Ophouden + + + diff --git a/calamares/src/libcalamares/testdata/yaml-list.conf b/calamares/src/libcalamares/testdata/yaml-list.conf new file mode 100644 index 0000000..fca1c4c --- /dev/null +++ b/calamares/src/libcalamares/testdata/yaml-list.conf @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# YAML dump +--- +"cow": "moo" +"derp": 17 +"dwarfs": + - "sleepy" + - "sneezy" + - "doc" +horse: + hoofs: 4 + colors: + mane: black + neck: roan + tail: white diff --git a/calamares/src/libcalamares/utils/CommandList.cpp b/calamares/src/libcalamares/utils/CommandList.cpp new file mode 100644 index 0000000..6e3434a --- /dev/null +++ b/calamares/src/libcalamares/utils/CommandList.cpp @@ -0,0 +1,318 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "CommandList.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "compat/Variant.h" +#include "locale/Global.h" +#include "utils/Logger.h" +#include "utils/Runner.h" +#include "utils/StringExpander.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include +#include + +namespace Calamares +{ + +static CommandList_t +get_variant_stringlist( const QVariantList& l ) +{ + CommandList_t retl; + unsigned int count = 0; + for ( const auto& v : l ) + { + if ( Calamares::typeOf( v ) == Calamares::StringVariantType ) + { + retl.append( CommandLine( v.toString(), CommandLine::TimeoutNotSet() ) ); + } + else if ( Calamares::typeOf( v ) == Calamares::MapVariantType ) + { + CommandLine command( v.toMap() ); + if ( command.isValid() ) + { + retl.append( command ); + } + // Otherwise warning is already given + } + else + { + cWarning() << "Bad CommandList element" << count << v; + } + ++count; + } + return retl; +} + +/** @brief Inserts the keys from @p map into @p expander as "gs"-keys + * + * For each key k in @p map, a key with literal `gs[` + prefix + '.' + key + + * literal `]` is inserted into the exapander. + */ +static void +expand_tree( Calamares::String::DictionaryExpander& expander, const QString& prefix, const QVariantMap& map ) +{ + // With the current prefix, turn a key into gs[prefix.key] + auto gs_key = [ &prefix ]( const QString& k ) -> QString + { return QStringLiteral( "gs[" ) + ( prefix.isEmpty() ? QString() : prefix + '.' ) + k + ']'; }; + + for ( QVariantMap::const_iterator valueiter = map.cbegin(); valueiter != map.cend(); ++valueiter ) + { + const QString key = valueiter.key(); + const QVariant value = valueiter.value(); + + switch ( Calamares::typeOf( value ) ) + { + case Calamares::MapVariantType: + expand_tree( expander, prefix.isEmpty() ? key : ( prefix + '.' + key ), value.toMap() ); + break; + case Calamares::StringVariantType: + expander.add( gs_key( key ), value.toString() ); + break; + case Calamares::IntVariantType: + expander.add( gs_key( key ), QString::number( value.toInt() ) ); + break; + default: + // Silently ignore + break; + } + } +} + +static Calamares::String::DictionaryExpander +get_gs_expander( System::RunLocation location ) +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + Calamares::String::DictionaryExpander expander; + + // Figure out the replacement for ${ROOT} + if ( location == System::RunLocation::RunInTarget ) + { + expander.insert( QStringLiteral( "ROOT" ), QStringLiteral( "/" ) ); + } + else if ( gs && gs->contains( "rootMountPoint" ) ) + { + expander.insert( QStringLiteral( "ROOT" ), gs->value( "rootMountPoint" ).toString() ); + } + + // Replacement for ${USER} + if ( gs && gs->contains( "username" ) ) + { + expander.insert( QStringLiteral( "USER" ), gs->value( "username" ).toString() ); + } + + if ( gs ) + { + const auto key = QStringLiteral( "LANG" ); + const QString lang = Calamares::Locale::readGS( *gs, key ); + if ( !lang.isEmpty() ) + { + expander.insert( key, lang ); + } + } + + if ( gs ) + { + expand_tree( expander, QString(), gs->data() ); + } + + return expander; +} + +CommandLine::CommandLine( const QVariantMap& m ) +{ + const QString command = Calamares::getString( m, "command" ); + const qint64 timeout = Calamares::getInteger( m, "timeout", -1 ); + if ( !command.isEmpty() ) + { + m_command = command; + m_timeout = timeout >= 0 ? std::chrono::seconds( timeout ) : CommandLine::TimeoutNotSet(); + m_environment = Calamares::getStringList( m, "environment" ); + + if ( m.contains( "verbose" ) ) + { + m_verbose = Calamares::getBool( m, "verbose", false ); + } + } + else + { + cWarning() << "Bad CommandLine element" << m; + // this CommandLine is invalid + } +} + +CommandLine +CommandLine::expand( KMacroExpanderBase& expander ) const +{ + // Calamares variable expansion in the command + QString c = m_command; + expander.expandMacrosShellQuote( c ); + + // .. and expand in each environment key=value string. + QStringList e = m_environment; + std::for_each( e.begin(), e.end(), [ &expander ]( QString& s ) { expander.expandMacrosShellQuote( s ); } ); + + CommandLine l { c, m_environment, m_timeout }; + if ( m_verbose.has_value() ) + { + l.updateVerbose( m_verbose.value() ); + } + return l; +} + +Calamares::CommandLine +CommandLine::expand() const +{ + auto expander = get_gs_expander( System::RunLocation::RunInHost ); + return expand( expander ); +} + +CommandList::CommandList( bool doChroot, std::chrono::seconds timeout ) + : m_doChroot( doChroot ) + , m_timeout( timeout ) +{ +} + +CommandList::CommandList::CommandList( const QVariant& v, bool doChroot, std::chrono::seconds timeout ) + : CommandList( doChroot, timeout ) +{ + if ( Calamares::typeOf( v ) == Calamares::ListVariantType ) + { + const auto v_list = v.toList(); + if ( v_list.count() ) + { + append( get_variant_stringlist( v_list ) ); + } + else + { + cWarning() << "Empty CommandList"; + } + } + else if ( Calamares::typeOf( v ) == Calamares::StringVariantType ) + { + append( { v.toString(), m_timeout } ); + } + else if ( Calamares::typeOf( v ) == Calamares::MapVariantType ) + { + CommandLine c( v.toMap() ); + if ( c.isValid() ) + { + append( c ); + } + // Otherwise warning is already given + } + else + { + cWarning() << "CommandList does not understand variant" << Calamares::typeOf( v ); + } +} + +Calamares::JobResult +CommandList::run() +{ + System::RunLocation location = m_doChroot ? System::RunLocation::RunInTarget : System::RunLocation::RunInHost; + + auto expander = get_gs_expander( location ); + auto expandedList = expand( expander ); + if ( expander.hasErrors() ) + { + const auto missing = expander.errorNames(); + cError() << "Missing variables:" << missing; + return Calamares::JobResult::error( + QCoreApplication::translate( "CommandList", "Could not run command." ), + QCoreApplication::translate( "CommandList", + "The commands use variables that are not defined. " + "Missing variables are: %1." ) + .arg( missing.join( ',' ) ) ); + } + + for ( CommandList::const_iterator i = expandedList.cbegin(); i != expandedList.cend(); ++i ) + { + QString processed_cmd = i->command(); + bool suppress_result = false; + if ( processed_cmd.startsWith( '-' ) ) + { + suppress_result = true; + processed_cmd.remove( 0, 1 ); // Drop the - + } + + const QString environmentSetting = []( const QStringList& l ) -> QString + { + if ( l.isEmpty() ) + { + return {}; + } + + return QStringLiteral( "export " ) + l.join( " " ) + QStringLiteral( " ; " ); + }( i->environment() ); + + QStringList shell_cmd { "/bin/sh", "-c" }; + shell_cmd << ( environmentSetting + processed_cmd ); + + std::chrono::seconds timeout = i->timeout() >= std::chrono::seconds::zero() ? i->timeout() : m_timeout; + + Calamares::Utils::Runner runner( shell_cmd ); + runner.setLocation( location ).setTimeout( timeout ).setWorkingDirectory( QString() ); + if ( i->isVerbose() ) + { + runner.enableOutputProcessing(); + QObject::connect( + &runner, &Calamares::Utils::Runner::output, []( QString output ) { cDebug() << output; } ); + } + ProcessResult r = runner.run(); + + if ( r.getExitCode() != 0 ) + { + if ( suppress_result ) + { + cDebug() << "Error code" << r.getExitCode() << "ignored by CommandList configuration."; + } + else + { + return r.explainProcess( processed_cmd, timeout ); + } + } + } + + return Calamares::JobResult::ok(); +} + +CommandList +CommandList::expand( KMacroExpanderBase& expander ) const +{ + // Copy and expand the list, collecting missing variables (so don't call expand()) + CommandList expandedList( m_doChroot, m_timeout ); + std::transform( cbegin(), + cend(), + std::back_inserter( expandedList ), + [ &expander ]( const CommandLine& c ) { return c.expand( expander ); } ); + return expandedList; +} + +CommandList +CommandList::expand() const +{ + auto expander = get_gs_expander( System::RunLocation::RunInHost ); + return expand( expander ); +} + +void +CommandList::updateVerbose( bool verbose ) +{ + std::for_each( begin(), end(), [ verbose ]( CommandLine& command ) { command.updateVerbose( verbose ); } ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/CommandList.h b/calamares/src/libcalamares/utils/CommandList.h new file mode 100644 index 0000000..b3d1fc4 --- /dev/null +++ b/calamares/src/libcalamares/utils/CommandList.h @@ -0,0 +1,167 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_COMMANDLIST_H +#define UTILS_COMMANDLIST_H + +#include "DllMacro.h" +#include "Job.h" + +#include +#include + +#include +#include +#include + +class KMacroExpanderBase; + +namespace Calamares +{ +/** + * Each command can have an associated timeout in seconds. The timeout + * defaults to 10 seconds. Provide some convenience naming and construction. + */ +class DLLEXPORT CommandLine +{ +public: + static inline constexpr std::chrono::seconds TimeoutNotSet() { return std::chrono::seconds( -1 ); } + + /// An invalid command line + CommandLine() = default; + + CommandLine( const QString& s ) + : m_command( s ) + { + } + + CommandLine( const QString& s, std::chrono::seconds t ) + : m_command( s ) + , m_timeout( t ) + { + } + + CommandLine( const QString& s, const QStringList& env, std::chrono::seconds t ) + : m_command( s ) + , m_environment( env ) + , m_timeout( t ) + { + } + + /** @brief Constructs a CommandLine from a map with keys + * + * Relevant keys are *command*, *environment* and *timeout*. + */ + CommandLine( const QVariantMap& m ); + + QString command() const { return m_command; } + [[nodiscard]] QStringList environment() const { return m_environment; } + std::chrono::seconds timeout() const { return m_timeout; } + bool isVerbose() const { return m_verbose.value_or( false ); } + + bool isValid() const { return !m_command.isEmpty(); } + + /** @brief Returns a copy of this one command, with variables expanded + * + * The given macro-expander is used to expand the command-line. + * This will normally be a Calamares::String::DictionaryExpander + * instance, which handles the ROOT and USER variables. + */ + DLLEXPORT CommandLine expand( KMacroExpanderBase& expander ) const; + + /** @brief As above, with a default macro-expander. + * + * The default macro-expander assumes RunInHost (e.g. ROOT will + * expand to the RootMountPoint set in Global Storage). + */ + DLLEXPORT CommandLine expand() const; + + /** @brief If nothing has set verbosity yet, update to @p verbose */ + void updateVerbose( bool verbose ) + { + if ( !m_verbose.has_value() ) + { + m_verbose = verbose; + } + } + + /** @brief Unconditionally set verbosity (can also reset it to nullopt) */ + void setVerbose( std::optional< bool > v ) { m_verbose = v; } + +private: + QString m_command; + QStringList m_environment; + std::chrono::seconds m_timeout = TimeoutNotSet(); + std::optional< bool > m_verbose; +}; + +/** @brief Abbreviation, used internally. */ +using CommandList_t = QList< CommandLine >; + +/** + * A list of commands; the list may have its own default timeout + * for commands (which is then applied to each individual command + * that doesn't have one of its own). + * + * Documentation for the format of commands can be found in + * `shellprocess.conf`. + */ +class DLLEXPORT CommandList : protected CommandList_t +{ +public: + /** @brief empty command-list with timeout to apply to entries. */ + CommandList( bool doChroot = true, std::chrono::seconds timeout = std::chrono::seconds( 10 ) ); + /** @brief command-list constructed from script-entries in @p v + * + * The global settings @p doChroot and @p timeout can be overridden by + * the individual script-entries. + */ + CommandList( const QVariant& v, bool doChroot = true, std::chrono::seconds timeout = std::chrono::seconds( 10 ) ); + CommandList( int ) = delete; + CommandList( const QVariant&, int ) = delete; + + bool doChroot() const { return m_doChroot; } + std::chrono::seconds defaultTimeout() const { return m_timeout; } + + Calamares::JobResult run(); + + using CommandList_t::at; + using CommandList_t::cbegin; + using CommandList_t::cend; + using CommandList_t::const_iterator; + using CommandList_t::count; + using CommandList_t::isEmpty; + using CommandList_t::push_back; + using CommandList_t::value_type; + + /** @brief Return a copy of this command-list, with variables expanded + * + * Each command-line in the list is expanded with the given @p expander. + * @see CommandLine::expand() for details. + */ + DLLEXPORT CommandList expand( KMacroExpanderBase& expander ) const; + + /** @brief As above, with a default macro-expander. + * + * Each command-line in the list is expanded with that default macro-expander. + * @see CommandLine::expand() for details. + */ + DLLEXPORT CommandList expand() const; + + /** @brief Applies default-value @p verbose to each entry without an explicit setting. */ + DLLEXPORT void updateVerbose( bool verbose ); + +private: + bool m_doChroot; + std::chrono::seconds m_timeout; +}; + +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamares/utils/Dirs.cpp b/calamares/src/libcalamares/utils/Dirs.cpp new file mode 100644 index 0000000..c42768a --- /dev/null +++ b/calamares/src/libcalamares/utils/Dirs.cpp @@ -0,0 +1,183 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2013-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from Tomahawk, portions: + * SPDX-FileCopyrightText: 2010-2011 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2010-2011 Leo Franchi + * SPDX-FileCopyrightText: 2010-2012 Jeff Mitchell + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Dirs.h" + +#include "CalamaresConfig.h" +#include "Logger.h" + +#include +#include +#include +#include +#include +#include + +#include + +using std::cerr; + +namespace Calamares +{ + +static QDir s_appDataDir( CMAKE_INSTALL_FULL_DATADIR ); +static bool s_isAppDataDirOverridden = false; + +static bool s_haveExtraDirs = false; +static QStringList s_extraConfigDirs; +static QStringList s_extraDataDirs; + +static bool +isWritableDir( const QDir& dir ) +{ + // We log with cerr here because we might be looking for the log dir + QString path = dir.absolutePath(); + if ( !dir.exists() ) + { + if ( !dir.mkpath( "." ) ) + { + cerr << "warning: failed to create " << qPrintable( path ) << '\n'; + return false; + } + return true; + } + + QFileInfo info( path ); + if ( !info.isDir() ) + { + cerr << "warning: " << qPrintable( path ) << " is not a dir\n"; + return false; + } + if ( !info.isWritable() ) + { + cerr << "warning: " << qPrintable( path ) << " is not writable\n"; + return false; + } + return true; +} + +void +setAppDataDir( const QDir& dir ) +{ + s_appDataDir = dir; + s_isAppDataDirOverridden = true; +} + +/* Split $ENV{@p name} on :, append to @p l, making sure each ends in / */ +static void +mungeEnvironment( QStringList& l, const char* name, const char* defaultDirs ) +{ + static const QString calamaresSubdir = QStringLiteral( "calamares/" ); + + QStringList dirs = QString( qgetenv( name ) ).split( ':' ); + if ( dirs.isEmpty() ) + { + dirs = QString( defaultDirs ).split( ':' ); + } + + for ( auto s : dirs ) + { + if ( s.isEmpty() ) + { + continue; + } + if ( s.endsWith( '/' ) ) + { + l << ( s + calamaresSubdir ) << s; + } + else + { + l << ( s + '/' + calamaresSubdir ) << ( s + '/' ); + } + } +} + +void +setXdgDirs() +{ + mungeEnvironment( s_extraConfigDirs, "XDG_CONFIG_DIRS", "/etc/xdg" ); + mungeEnvironment( s_extraDataDirs, "XDG_DATA_DIRS", "/usr/local/share/:/usr/share/" ); + + s_haveExtraDirs = !( s_extraConfigDirs.isEmpty() && s_extraDataDirs.isEmpty() ); +} + +QStringList +extraConfigDirs() +{ + if ( s_haveExtraDirs ) + { + return s_extraConfigDirs; + } + return QStringList(); +} + +QStringList +extraDataDirs() +{ + if ( s_haveExtraDirs ) + { + return s_extraDataDirs; + } + return QStringList(); +} + +bool +haveExtraDirs() +{ + return s_haveExtraDirs && ( !s_extraConfigDirs.isEmpty() || !s_extraDataDirs.isEmpty() ); +} + +bool +isAppDataDirOverridden() +{ + return s_isAppDataDirOverridden; +} + +QDir +appDataDir() +{ + return s_appDataDir; +} + +QDir +systemLibDir() +{ + QDir path( CMAKE_INSTALL_FULL_LIBDIR ); + return path; +} + +QDir +appLogDir() +{ + QString path = QStandardPaths::writableLocation( QStandardPaths::CacheLocation ); + QDir dir( path ); + if ( isWritableDir( dir ) ) + { + return dir; + } + + cerr << "warning: Could not find a standard writable location for log dir, falling back to $HOME\n"; + dir = QDir::home(); + if ( isWritableDir( dir ) ) + { + return dir; + } + + cerr << "warning: Found no writable location for log dir, falling back to the temp dir\n"; + return QDir::temp(); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/Dirs.h b/calamares/src/libcalamares/utils/Dirs.h new file mode 100644 index 0000000..d0edd7a --- /dev/null +++ b/calamares/src/libcalamares/utils/Dirs.h @@ -0,0 +1,61 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2013-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from Tomahawk, portions: + * SPDX-FileCopyrightText: 2010-2011 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2010-2011 Leo Franchi + * SPDX-FileCopyrightText: 2010-2012 Jeff Mitchell + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_DIRS_H +#define UTILS_DIRS_H + +#include "DllMacro.h" + +#include + +namespace Calamares +{ +/** + * @brief appDataDir returns the directory with common application data. + * Defaults to CMAKE_INSTALL_FULL_DATADIR (usually /usr/share/calamares). + */ +DLLEXPORT QDir appDataDir(); + +/** + * @brief appLogDir returns the directory for Calamares logs. + * Defaults to QStandardPaths::CacheLocation (usually ~/.cache/Calamares). + */ +DLLEXPORT QDir appLogDir(); + +/** + * @brief systemLibDir returns the system's lib directory. + * Defaults to CMAKE_INSTALL_FULL_LIBDIR (usually /usr/lib64 or /usr/lib). + */ +DLLEXPORT QDir systemLibDir(); + +/** + * Override app data dir. Only for testing purposes. + */ +DLLEXPORT void setAppDataDir( const QDir& dir ); +DLLEXPORT bool isAppDataDirOverridden(); + +/** @brief Setup extra config and data dirs from the XDG variables. + */ +DLLEXPORT void setXdgDirs(); +/** @brief Are any extra directories configured? */ +DLLEXPORT bool haveExtraDirs(); +/** @brief XDG_CONFIG_DIRS, each guaranteed to end with / */ +DLLEXPORT QStringList extraConfigDirs(); +/** @brief XDG_DATA_DIRS, each guaranteed to end with / */ +DLLEXPORT QStringList extraDataDirs(); +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/Entropy.cpp b/calamares/src/libcalamares/utils/Entropy.cpp new file mode 100644 index 0000000..4d1400c --- /dev/null +++ b/calamares/src/libcalamares/utils/Entropy.cpp @@ -0,0 +1,122 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Entropy.h" + +#include + +#include + +namespace Calamares +{ +EntropySource +getEntropy( int size, QByteArray& b ) +{ + constexpr const char filler = char( 0xcb ); + + b.fill( filler ); + b.clear(); + if ( size < 1 ) + { + return EntropySource::None; + } + + b.fill( filler, size ); + char* buffer = b.data(); + + qint64 readSize = 0; + QFile urandom( "/dev/urandom" ); + if ( urandom.exists() && urandom.open( QIODevice::ReadOnly ) ) + { + readSize = urandom.read( buffer, size ); + urandom.close(); + } + + if ( readSize >= size ) + { + return EntropySource::URandom; + } + + // If it wasn't available, or did not return enough bytes, + // complete it with twister (and tell the client). + std::random_device r; + std::seed_seq seed { r(), r(), r(), r(), r(), r(), r(), r() }; + std::mt19937_64 twister( seed ); + + std::uint64_t next = 0; + do + { + next = twister(); + // Eight times, for a 64-bit next +#define GET_ONE_BYTE \ + if ( readSize < size ) \ + { \ + buffer[ readSize++ ] = char( next & 0xffU ); \ + next = next >> 8; \ + } + GET_ONE_BYTE + GET_ONE_BYTE + GET_ONE_BYTE + GET_ONE_BYTE + GET_ONE_BYTE + GET_ONE_BYTE + GET_ONE_BYTE + GET_ONE_BYTE + } while ( readSize < size ); + + return EntropySource::Twister; +} + +EntropySource +getPrintableEntropy( int size, QString& s ) +{ + s.clear(); + if ( size < 1 ) + { + return EntropySource::None; + } + + static const char salt_chars[] = { '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', + 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' }; + static_assert( sizeof( salt_chars ) == 64, "Missing salt_chars" ); + + // Number of bytes we're going to need + int byteSize = ( ( size * 6 ) / 8 ) + 1; + QByteArray b; + EntropySource r = getEntropy( byteSize, b ); + + int bitsLeft = 0; + int byteOffset = 0; + qint64 next = 0; + do + { + if ( bitsLeft < 6 ) + { + next = ( next << 8 ) | b.at( byteOffset++ ); + bitsLeft += 8; + } + char c = salt_chars[ next & 0b0111111 ]; + next >>= 6; + bitsLeft -= 6; + s.append( c ); + } while ( ( s.length() < size ) && ( byteOffset < b.size() ) ); + + if ( s.length() < size ) + { + // It's incomplete, not really no-entropy + return EntropySource::None; + } + + return r; +} +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/Entropy.h b/calamares/src/libcalamares/utils/Entropy.h new file mode 100644 index 0000000..a28c95c --- /dev/null +++ b/calamares/src/libcalamares/utils/Entropy.h @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_ENTROPY_H +#define UTILS_ENTROPY_H + +#include "DllMacro.h" + +#include + +namespace Calamares +{ +/// @brief Which entropy source was actually used for the entropy. +enum class EntropySource +{ + None, ///< Buffer is empty, no random data + URandom, ///< Read from /dev/urandom + Twister ///< Generated by pseudo-random +}; + +/** @brief Fill buffer @p b with exactly @p size random bytes + * + * The array is cleared and resized, then filled with 0xcb + * "just in case", after which it is filled with random + * bytes from a suitable source. Returns which source was used. + */ +DLLEXPORT EntropySource getEntropy( int size, QByteArray& b ); + +/** @brief Fill string @p s with exactly @p size random printable ASCII characters + * + * The characters are picked from a set of 64 (2^6). The string + * contains 6 * size bits of entropy. * Returns which source was used. + * @see getEntropy + */ +DLLEXPORT EntropySource getPrintableEntropy( int size, QString& s ); +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/Logger.cpp b/calamares/src/libcalamares/utils/Logger.cpp new file mode 100644 index 0000000..dcd6c51 --- /dev/null +++ b/calamares/src/libcalamares/utils/Logger.cpp @@ -0,0 +1,311 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2010-2011 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Logger.h" + +#include "CalamaresVersionX.h" +#include "compat/Mutex.h" +#include "compat/Variant.h" +#include "utils/Dirs.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +static constexpr const int LOGFILE_SIZE = 1024 * 256; + +static std::ofstream logfile; +static unsigned int s_threshold = +#ifdef QT_NO_DEBUG + Logger::LOG_DISABLE; +#else + Logger::LOGDEBUG; // Comparison is < in log() function +#endif +static QMutex s_mutex; + +static const char s_Continuation[] = "\n "; +static const char s_SubEntry[] = " .. "; + +namespace Logger +{ + +void +setupLogLevel( unsigned int level ) +{ + if ( level > LOGVERBOSE ) + { + level = LOGVERBOSE; + } + s_threshold = level + 1; // Comparison is < in logLevelEnabled() function +} + +unsigned int +logLevel() +{ + // Undo the +1 in setupLogLevel() + return s_threshold > 0 ? s_threshold - 1 : 0; +} + +bool +logLevelEnabled( unsigned int level ) +{ + return level < s_threshold; +} + +/** @brief Should we call the log_implementation() function with this level? + * + * The implementation logs everything for which logLevelEnabled() is + * true to the file **and** to stdout; it logs everything at debug-level + * or below to the file regardless. + */ +static inline bool +log_enabled( unsigned int level ) +{ + return level <= LOGDEBUG || logLevelEnabled( level ); +} + +static void +log_implementation( const char* msg, unsigned int debugLevel, const char* funcinfo ) +{ + Calamares::MutexLocker lock( &s_mutex ); + + const auto date = QDate::currentDate().toString( Qt::ISODate ); + const auto time = QTime::currentTime().toString(); + + // If we don't format the date as a Qt::ISODate then we get a crash when + // logging at exit as Qt tries to use QLocale to format, but QLocale is + // on its way out. + if ( funcinfo ) + { + logfile << date.toUtf8().data() << " - " << time.toUtf8().data() << " [" << debugLevel << "]: " << funcinfo + << '\n'; + } + if ( msg ) + { + logfile << date.toUtf8().data() << " - " << time.toUtf8().data() << " [" << debugLevel + << ( funcinfo ? "]: " : "]: " ) << msg << '\n'; + } + logfile.flush(); + + if ( logLevelEnabled( debugLevel ) ) + { + if ( funcinfo ) + { + std::cout << time.toUtf8().data() << " [" << debugLevel << "]: " << funcinfo + << ( msg ? s_Continuation : "" ); + } + // The endl is desired, since it also flushes (like the logfile, above) + std::cout << ( msg ? msg : "" ) << std::endl; + } +} + +static void +CalamaresLogHandler( QtMsgType type, const QMessageLogContext&, const QString& msg ) +{ + unsigned int level = LOGVERBOSE; + const char* funcinfo = nullptr; + switch ( type ) + { + case QtInfoMsg: + level = LOGVERBOSE; + funcinfo = "INFO"; + break; + case QtDebugMsg: + level = LOGDEBUG; + funcinfo = "DEBUG"; + break; + case QtWarningMsg: + level = LOGWARNING; + funcinfo = "WARNING"; + break; + case QtCriticalMsg: + case QtFatalMsg: + level = LOGERROR; + funcinfo = "ERROR"; + break; + } + + if ( !log_enabled( level ) ) + { + return; + } + + log_implementation( + nullptr, level, ( QString( funcinfo ) + QStringLiteral( " (Qt): " ) + msg ).toUtf8().constData() ); +} + +QString +logFile() +{ + return Calamares::appLogDir().filePath( "session.log" ); +} + +void +setupLogfile() +{ + if ( QFileInfo( logFile().toLocal8Bit() ).size() > LOGFILE_SIZE ) + { + QByteArray lc; + { + QFile f( logFile().toLocal8Bit() ); + f.open( QIODevice::ReadOnly | QIODevice::Text ); + lc = f.readAll(); + f.close(); + } + + QFile::remove( logFile().toLocal8Bit() ); + + { + QFile f( logFile().toLocal8Bit() ); + f.open( QIODevice::WriteOnly | QIODevice::Text ); + f.write( lc.right( LOGFILE_SIZE - ( LOGFILE_SIZE / 4 ) ) ); + f.close(); + } + } + + // Since the log isn't open yet, this probably only goes to stdout + cDebug() << "Using log file:" << logFile(); + + // Lock while (re-)opening the logfile + { + Calamares::MutexLocker lock( &s_mutex ); + logfile.open( logFile().toLocal8Bit(), std::ios::app ); + if ( logfile.tellp() ) + { + logfile << "\n\n" << std::endl; + } + logfile << "=== START CALAMARES " << CALAMARES_VERSION << std::endl; + } + + qInstallMessageHandler( CalamaresLogHandler ); +} + +CDebug::CDebug( unsigned int debugLevel, const char* func ) + : QDebug( &m_msg ) + , m_debugLevel( debugLevel ) + , m_funcinfo( func ) +{ + if ( debugLevel <= LOGERROR ) + { + m_msg = QStringLiteral( "ERROR: " ); + } + else if ( debugLevel <= LOGWARNING ) + { + m_msg = QStringLiteral( "WARNING: " ); + } +} + +CDebug::~CDebug() +{ + if ( log_enabled( m_debugLevel ) ) + { + log_implementation( m_msg.toUtf8().data(), m_debugLevel, m_funcinfo ); + } +} + +constexpr FuncSuppressor::FuncSuppressor( const char s[] ) + : m_s( s ) +{ +} + +const constexpr FuncSuppressor Continuation( s_Continuation ); +const constexpr FuncSuppressor SubEntry( s_SubEntry ); +const constexpr NoQuote_t NoQuote {}; +const constexpr Quote_t Quote {}; + +QString +toString( const QVariant& v ) +{ + auto t = Calamares::typeOf( v ); + + if ( t == Calamares::ListVariantType ) + { + QStringList s; + auto l = v.toList(); + for ( auto lit = l.constBegin(); lit != l.constEnd(); ++lit ) + { + s << lit->toString(); + } + return s.join( ", " ); + } + else + { + return v.toString(); + } +} + +QDebug& +operator<<( QDebug& s, const RedactedCommand& l ) +{ + // Special case logging: don't log the (encrypted) password. + if ( l.list.contains( "usermod" ) ) + { + for ( const auto& item : l.list ) + { + if ( item.startsWith( "$6$" ) ) + { + s << ""; + } + else + { + s << item; + } + } + } + else + { + s << l.list; + } + + return s; +} + +/** @brief Returns a stable-but-private hash of @p context and @p s + * + * Identical strings with the same context will be hashed the same, + * so that they can be logged and still recognized as the-same. + */ +static uint +insertRedactedName( const QString& context, const QString& s ) +{ + static uint salt = QRandomGenerator::global()->generate(); // Just once + + uint val = qHash( context, salt ); + return qHash( s, val ); +} + +RedactedName::RedactedName( const QString& context, const QString& s ) + : m_id( insertRedactedName( context, s ) ) + , m_context( context ) +{ +} + +RedactedName::RedactedName( const char* context, const QString& s ) + : RedactedName( QString::fromLatin1( context ), s ) +{ +} + +RedactedName::operator QString() const +{ + return QString( m_context + '$' + QString::number( m_id, 16 ) ); +} + +} // namespace Logger diff --git a/calamares/src/libcalamares/utils/Logger.h b/calamares/src/libcalamares/utils/Logger.h new file mode 100644 index 0000000..8d3301d --- /dev/null +++ b/calamares/src/libcalamares/utils/Logger.h @@ -0,0 +1,404 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2010-2011 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_LOGGER_H +#define UTILS_LOGGER_H + +#include "DllMacro.h" + +#include +#include +#include + +#include + +namespace Logger +{ +class Once; + +struct FuncSuppressor +{ + explicit constexpr FuncSuppressor( const char[] ); + const char* m_s; +}; + +struct NoQuote_t +{ +}; +struct Quote_t +{ +}; + +DLLEXPORT extern const FuncSuppressor Continuation; +DLLEXPORT extern const FuncSuppressor SubEntry; +DLLEXPORT extern const NoQuote_t NoQuote; +DLLEXPORT extern const Quote_t Quote; + +enum +{ + LOG_DISABLE = 0, + LOGERROR = 1, + LOGWARNING = 2, + LOGDEBUG = 6, + LOGVERBOSE = 8 +}; + +class DLLEXPORT CDebug : public QDebug +{ +public: + explicit CDebug( unsigned int debugLevel = LOGDEBUG, const char* func = nullptr ); + virtual ~CDebug(); + + friend CDebug& operator<<( CDebug&&, const FuncSuppressor& ); + friend CDebug& operator<<( CDebug&&, const Once& ); + + inline unsigned int level() const { return m_debugLevel; } + +private: + QString m_msg; + unsigned int m_debugLevel; + const char* m_funcinfo = nullptr; +}; + +inline CDebug& +operator<<( CDebug&& s, const FuncSuppressor& f ) +{ + if ( s.m_funcinfo ) + { + s.m_funcinfo = nullptr; + s.m_msg = QString( f.m_s ); + } + return s; +} + +inline QDebug& +operator<<( QDebug& s, const FuncSuppressor& f ) +{ + return s << f.m_s; +} + +inline QDebug& +operator<<( QDebug& s, const NoQuote_t& ) +{ + return s.noquote().nospace(); +} + +inline QDebug& +operator<<( QDebug& s, const Quote_t& ) +{ + return s.quote().space(); +} + +/** + * @brief The full path of the log file. + */ +DLLEXPORT QString logFile(); + +/** + * @brief Start logging to the log file. + * + * Call this (once) to start logging to the log file (usually + * ~/.cache/calamares/session.log ). An existing log file is + * rolled over if it is too large. + */ +DLLEXPORT void setupLogfile(); + +/** + * @brief Set a log level for future logging. + * + * Pass in a value from the LOG* enum, above. Use 0 to + * disable logging. Values greater than LOGVERBOSE are + * limited to LOGVERBOSE, which will log everything. + * + * Practical values are 0, 1, 2, and 6. + */ +DLLEXPORT void setupLogLevel( unsigned int level ); + +/** @brief Return the configured log-level. */ +DLLEXPORT unsigned int logLevel(); + +/** @brief Would the given @p level really be logged? */ +DLLEXPORT bool logLevelEnabled( unsigned int level ); + +/** + * @brief Row-oriented formatted logging. + * + * Use DebugRow to produce multiple rows of 2-column output + * in a debugging statement. For instance, + * cDebug() << DebugRow(1,12) + * << DebugRow(2,24) + * will produce a single timestamped debug line with continuations. + * Each DebugRow produces one line of output, with the two values. + */ +template < typename T, typename U > +struct DebugRow +{ +public: + explicit DebugRow( const T& t, const U& u ) + : first( t ) + , second( u ) + { + } + + const T first; + const U second; +}; + +/** + * @brief List-oriented formatted logging. + * + * Use DebugList to produce multiple rows of output in a debugging + * statement. For instance, + * cDebug() << DebugList( QStringList() << "foo" << "bar" ) + * will produce a single timestamped debug line with continuations. + * Each element of the list of strings will be logged on a separate line. + */ +/* TODO: Calamares 3.3, bump requirements to C++17, and rename + * this to DebugList, dropping the convenience-definition + * below. In C++17, class template argument deduction is + * added, so `DebugList( whatever )` determines the right + * type already (also for QStringList). + */ +template < typename T > +struct DebugListT +{ + using list_t = QList< T >; + + explicit DebugListT( const list_t& l ) + : list( l ) + { + } + + const list_t& list; +}; + +///@brief Convenience for QStringList, needs no template parameters +struct DebugList : public DebugListT< QString > +{ + explicit DebugList( const list_t& l ) + : DebugListT( l ) + { + } +}; + +/** + * @brief Map-oriented formatted logging. + * + * Use DebugMap to produce multiple rows of output in a debugging + * statement from a map. The output is intentionally a bit-yaml-ish. + * cDebug() << DebugMap( map ) + * will produce a single timestamped debug line with continuations. + * The continued lines will have a key (from the map) and a value + * on each line. + */ +struct DebugMap +{ +public: + explicit DebugMap( const QVariantMap& m ) + : map( m ) + { + } + + const QVariantMap& map; +}; + +/** @brief When logging commands, don't log everything. + * + * The command-line arguments to some commands may contain the + * encrypted password set by the user. Don't log that password, + * since the log may get posted to bug reports, or stored in + * the target system. + */ +struct RedactedCommand +{ + RedactedCommand( const QStringList& l ) + : list( l ) + { + } + + const QStringList& list; +}; + +DLLEXPORT QDebug& operator<<( QDebug& s, const RedactedCommand& l ); + +/** @brief When logging "private" identifiers, keep them consistent but private + * + * Send a string to a logger in such a way that each time it is logged, + * it logs the same way, but without revealing the actual contents. + * This can be applied to user names, UUIDs, etc. + */ +struct DLLEXPORT RedactedName +{ + RedactedName( const char* context, const QString& s ); + RedactedName( const QString& context, const QString& s ); + + operator QString() const; + +private: + const uint m_id; + const QString m_context; +}; + +inline QDebug& +operator<<( QDebug& s, const RedactedName& n ) +{ + return s << NoQuote << QString( n ) << Quote; +} + +/** + * @brief Formatted logging of a pointer + * + * Pointers are printed as void-pointer, so just an address (unlike, say, + * QObject pointers which show an address and some metadata) preceded + * by an '@'. This avoids C-style (void*) casts in the code. + * + * Shared pointers are indicated by 'S@' and unique pointers by 'U@'. + */ +struct Pointer +{ +public: + explicit Pointer( const void* p ) + : ptr( p ) + , kind( 0 ) + { + } + + template < typename T > + explicit Pointer( const std::shared_ptr< T >& p ) + : ptr( p.get() ) + , kind( 'S' ) + { + } + + template < typename T > + explicit Pointer( const std::unique_ptr< T >& p ) + : ptr( p.get() ) + , kind( 'U' ) + { + } + + const void* const ptr; + const char kind; +}; + +/** @brief output operator for DebugRow */ +template < typename T, typename U > +inline QDebug& +operator<<( QDebug& s, const DebugRow< T, U >& t ) +{ + s << Continuation << t.first << ':' << ' ' << t.second; + return s; +} + +/** @brief output operator for DebugList, assuming operator<< for T exists */ +template < typename T = QString > +inline QDebug& +operator<<( QDebug& s, const DebugListT< T >& c ) +{ + for ( const auto& i : c.list ) + { + s << Continuation << i; + } + return s; +} + +/** @brief supporting method for outputting a DebugMap */ +DLLEXPORT QString toString( const QVariant& v ); + +/** @brief output operator for DebugMap */ +inline QDebug& +operator<<( QDebug& s, const DebugMap& t ) +{ + for ( auto it = t.map.constBegin(); it != t.map.constEnd(); ++it ) + { + s << Continuation << it.key().toUtf8().constData() << ':' << ' ' << toString( it.value() ).toUtf8().constData(); + } + return s; +} + +inline QDebug& +operator<<( QDebug& s, const Pointer& p ) +{ + s << NoQuote; + if ( p.kind ) + { + s << p.kind; + } + s << '@' << p.ptr << Quote; + return s; +} + +/** @brief Convenience object for supplying SubEntry to a debug stream + * + * In a function with convoluted control paths, it may be unclear + * when to supply SubEntry to a debug stream -- it is convenient + * for the **first** debug statement from a given function to print + * the function header, and all subsequent onces to get SubEntry. + * + * Create an object of type Once and send it (first) to all CDebug + * objects; this will print the function header only once within the + * lifetime of that Once object. + */ +class Once +{ +public: + Once() + : m( true ) + { + } + friend CDebug& operator<<( CDebug&&, const Once& ); + + /** @brief Restore the object to "fresh" state + * + * It may be necessary to allow the Once object to stream the + * function header again -- for instance, after logging an error, + * any following debug log might want to re-introduce the header. + */ + void refresh() { m = true; } + + /** @brief Is this object "fresh"? + * + * Once a Once-object has printed (once) it is no longer fresh. + */ + operator bool() const { return m; } + +private: + mutable bool m = false; +}; + +inline CDebug& +operator<<( CDebug&& s, const Once& o ) +{ + if ( !logLevelEnabled( s.level() ) ) + { + // This won't print, so it's not using the "onceness" + return s; + } + + if ( o.m ) + { + o.m = false; + return s; + } + s.m_funcinfo = nullptr; + s << SubEntry; + return s; +} + +} // namespace Logger + +#define cVerbose() Logger::CDebug( Logger::LOGVERBOSE, Q_FUNC_INFO ) +#define cDebug() Logger::CDebug( Logger::LOGDEBUG, Q_FUNC_INFO ) +#define cWarning() Logger::CDebug( Logger::LOGWARNING, Q_FUNC_INFO ) +#define cError() Logger::CDebug( Logger::LOGERROR, Q_FUNC_INFO ) + +#endif diff --git a/calamares/src/libcalamares/utils/NamedEnum.h b/calamares/src/libcalamares/utils/NamedEnum.h new file mode 100644 index 0000000..d93287b --- /dev/null +++ b/calamares/src/libcalamares/utils/NamedEnum.h @@ -0,0 +1,263 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/** @brief Support for "named" enumerations + * + * When a string needs to be one specific string out of a set of + * alternatives -- one "name" from an enumerated set -- then it + * is useful to have an **enum type** for the enumeration so that + * C++ code can work with the (strong) type of the enum, while + * the string can be used for human-readable interaction. + * The `NamedEnumTable` template provides support for naming + * values of an enum. + */ + +#ifndef UTILS_NAMEDENUM_H +#define UTILS_NAMEDENUM_H + +#include + +#include +#include +#include + +/** @brief Type for collecting parts of a named enum. + * + * The `NamedEnumTable` template provides support for naming + * values of an enum. It supports mapping strings to enum values + * and mapping enum values to strings. + * + * ## Example + * + * Suppose we have code where there are three alternatives; it is + * useful to have a strong type to make the alternatives visible + * in that code, so the compiler can help check: + * + * ``` + * enum class MilkshakeSize { None, Small, Large }; + * ``` + * + * In a switch() statement, the compiler will check that all kinds + * of milkshakes are dealt with; we can pass a MilkshakeSize to + * a function and rest assured that nobody will call that function + * with a silly value, like `1`. + * + * There is no relation between the C++ identifiers used, and + * any I/O related to that enumeration. In other words, + * + * ``` + * std::cout << MilkshakeSize::Small; + * ``` + * + * Will **not** print out "Small", or "small", or 1. It won't even + * compile, because there is no mapping of the enum values to + * something that can be output. + * + * By making a `NamedEnumTable` we can define a mapping + * between strings (names) and enum values, so that we can easily + * output the human-readable name, and also take string input + * and convert it to an enum value. Suppose we have a function + * `milkshakeSizeNames()` that returns a reference to such a table, + * then we can use `find()` to map enums-to-names and names-to-enums. + * + * ``` + * const auto& names = milkshakeSizeNames(); + * MilkshakeSize sz{ MilkshakeSize::Large }; + * std::cout << names.find(sz); // Probably "large" + * + * bool ok; + * sz = names.find( "small", ok ); // Probably MilkshakeSize::Small + * ``` + * + * ## Usage + * + * It is recommended to use a static const declaration for the table; + * typical use will define a function that returns a reference to + * the table, for shared use. + * + * The constructor for a table takes an initializer_list; each element + * of the initializer_list is a **pair** consisting of a name and + * an associated enum value. The names should be QStrings. For each enum + * value that is listed, the canonical name should come **first** in the + * table, so that printing the enum values gives the canonical result. + * + * ``` + * static const NamedEnumTable& milkshakeSizeNames() + * { + * static NamedEnumTable n { // Initializer list for n + * { "large", MilkshakeSize::Large }, // One pair of name-and-value + * { "small", MilkshakeSize::Small }, + * { "big", MilkshakeSize::Large } + * }; + * return n; + * } + * ``` + * + * The function `eNames()`, above, returns a reference to a name table + * for the enum (presumably an enum class) `E`. It is possible to have + * more than one table for an enum, or to make the table locally, + * but **usually** you want one definitive table of names and values. + * The `eNames()` function gives you that definitive table. In Calamres + * code, such functions are usually named after the underlying enum. + * + * Using this particular table, looking up "large" will return `MilkshakeSize::Large`, + * looking up "big" will **also** return `MilkshakeSize::Large`, looking up "derp" + * will return `MilkshakeSize::Large` (because that is the first value in the table) + * but will set the boolean `ok` parameter to false. Conversely, looking + * up `MilkshakeSize::Large` will return "large" (never "big"). + * + * Note that this particular table does **not** name MilkshakeSize::None, + * so it is probably wrong: you can't get a string for that enum + * value, and no string will map to MilkshakeSize::None either. + * In general, tables should cover all of the enum values. + * + * Passing an empty initializer_list to the constructor is supported, + * but will cause UB if the table is ever used for looking up a string. + * + */ +template < typename T > +struct NamedEnumTable +{ + using string_t = QString; + using enum_t = T; + using pair_t = std::pair< string_t, enum_t >; + using type = std::vector< pair_t >; + + type table; + + /** @brief Create a table of named enum values. + * + * Use braced-initialisation for NamedEnum, and remember that the + * elements of the list are **pairs**, e.g. + * + * ``` + * static const NamedEnumTable c{ {"red", Colors::Red } }; + * ``` + */ + NamedEnumTable( const std::initializer_list< pair_t >& v ) + : table( v ) + { + /* static_assert( v.size() > 0 ); */ + } + + /** @brief Find a name @p s in the table. + * + * Searches case-insensitively. + * + * If the name @p s is not found, @p ok is set to @c false and + * the first enum value in the table is returned. Otherwise, + * @p ok is set to @c true and the corresponding value is returned. + * Use the output value of @p ok to determine if the lookup was + * successful: there is otherwise no sensible way to distinguish + * found-the-name-of-the-first-item from not-found. + */ + enum_t find( const string_t& s, bool& ok ) const + { + ok = false; + + for ( const auto& p : table ) + { + if ( 0 == QString::compare( s, p.first, Qt::CaseInsensitive ) ) + { + ok = true; + return p.second; + } + } + + // ok is still false + return table.begin()->second; + } + + /** @brief Find a name @p s in the table. + * + * Searches case-insensitively. + * + * If the name @p s is not found, the value @p d is returned as + * a default. Otherwise the value corresponding to @p s is returned. + * This is a shortcut over find() using a bool to distinguish + * successful and unsuccesful lookups. + */ + enum_t find( const string_t& s, enum_t d ) const + { + bool ok = false; + enum_t e = find( s, ok ); + return ok ? e : d; + } + + /** @brief Find a value @p s in the table and return its name. + * + * If @p s is an enum value in the table, return the corresponding + * name (the first name with that value, if there are aliases) + * and set @p ok to @c true. + * + * If the value @p s is not found, @p ok is set to @c false and + * an empty string is returned. This indicates that the table does + * not cover all of the values * in `enum_t` (and @p s is one + * of them), **or** that the passed-in value of @p s is + * not a legal value, e.g. via a static_cast. + */ + string_t find( enum_t s, bool& ok ) const + { + ok = false; + + for ( const auto& p : table ) + { + if ( s == p.second ) + { + ok = true; + return p.first; + } + } + + // ok is still false + return string_t(); + } + + /** @brief Find a value @p s in the table and return its name. + * + * Returns an empty string if the value @p s is not found (this + * indicates that the table does not cover all of the values + * in `enum_t`, **or** that the passed-in value of @p s is + * not a legal value, e.g. via a static_cast). + */ + string_t find( enum_t s ) const + { + bool ok = false; + return find( s, ok ); + } +}; + +/** @brief Smashes an enum value to its underlying type. + * + * While an enum **class** is not an integral type, and its values can't be + * printed or treated like an integer (like an old-style enum can), + * the underlying type **is** integral. This template function + * returns the value of an enum value, in its underlying type. + * This can be useful for debugging purposes, e.g. + * + * ``` + * MilkshakeSize sz{ MilkshakeSize::None }; + * std::cout << milkshakeSizeNames().find( sz ) << smash( sz ); + * ``` + * + * This will print both the name and the underlying integer for the + * value; assuming the table from the example is used, there is + * no name for MilkshakeSize::None, so it will print an empty string, + * followed by the integral representation -- probably a 0. + */ +template < typename E > +constexpr typename std::underlying_type< E >::type +smash( const E e ) +{ + return static_cast< typename std::underlying_type< E >::type >( e ); +} + +#endif diff --git a/calamares/src/libcalamares/utils/NamedSuffix.h b/calamares/src/libcalamares/utils/NamedSuffix.h new file mode 100644 index 0000000..8454bbe --- /dev/null +++ b/calamares/src/libcalamares/utils/NamedSuffix.h @@ -0,0 +1,100 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/** @brief Support for unit-suffixed values. + * + * This combines a value with an (enum) unit indicating what kind + * of value it is, e.g. 10 meters, or 64 pixels. Includes simple + * parsing support for the values written as strings like , + * e.g. "10m" or "64px". + * + * When a suffixed unit value needs validation, define an isValid() + * method; similarly for simple construction from a string (with a fixed + * table of suffixes). Typical use then looks like: + * + * class MyUnit : public NamedSuffix + * { + * public: + * using NamedSuffix::NamedSuffix; // Keep existing constructors + * MyUnit( const QString& s ); + * bool isValid() const; + * } ; + */ + +#ifndef UTILS_NAMEDSUFFIX_H +#define UTILS_NAMEDSUFFIX_H + +#include "NamedEnum.h" + +/** @brief Template that takes the enum type to work with and a special none-enum. */ +template < typename T, T _none > +class NamedSuffix +{ +public: + using unit_t = T; + + static constexpr unit_t none = _none; + + /** @brief Empty value. */ + NamedSuffix() + : m_value( 0 ) + , m_unit( none ) + { + } + + /** @brief Specific value and unit. */ + NamedSuffix( qint64 value, unit_t unit ) + : m_value( value ) + , m_unit( unit ) + { + } + + /** @brief Construct value and unit from string. + * + * This parses the given string @p s by comparing with the suffixes + * in @p table and uses the first matching suffix as the unit. + */ + NamedSuffix( const NamedEnumTable< T >& table, const QString& s ) + : NamedSuffix() + { + for ( const auto& suffix : table.table ) + { + if ( s.endsWith( suffix.first ) ) + { + m_value = s.left( s.length() - suffix.first.length() ).toLongLong(); + m_unit = suffix.second; + break; + } + } + } + + /** @brief Construct value from string. + * + * This is not defined in the template, because it should probably + * delegate to the constructor above with a fixed table. + */ + NamedSuffix( const QString& s ); + + qint64 value() const { return m_value; } + unit_t unit() const { return m_unit; } + + /** @brief Check that a value-unit combination is valid. + * + * This is not defined in the template, because validity (e.g. + * range of acceptable values) depends on the kind of unit. + */ + bool isValid() const; + +protected: + qint64 m_value; + unit_t m_unit; +}; + +#endif diff --git a/calamares/src/libcalamares/utils/Permissions.cpp b/calamares/src/libcalamares/utils/Permissions.cpp new file mode 100644 index 0000000..aac47a2 --- /dev/null +++ b/calamares/src/libcalamares/utils/Permissions.cpp @@ -0,0 +1,212 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Scott Harvey + * SPDX-FileCopyrightText: 2024 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "Permissions.h" + +#include "Logger.h" +#include "System.h" + +#include +#include + +#include + +// Massaged and re-named from https://euroquis.nl/blabla/2024/04/30/chmod.html for C++17 +namespace +{ + +template < int position, char accept > +int +expectCharacterAtPosition( const QString& s ) +{ + const QChar unicode = s.at( position ); + if ( unicode.row() != 0 ) + { + return -1; + } + + const char c = char( unicode.cell() ); // cell() returns uchar + if ( c == accept ) + { + return 1 << ( 8 - position ); + } + if ( c == '-' ) + { + return 0; + } + return -1; +} + +int +modeFromVerboseString( const QString& s ) +{ + return expectCharacterAtPosition< 0, 'r' >( s ) | expectCharacterAtPosition< 1, 'w' >( s ) + | expectCharacterAtPosition< 3, 'r' >( s ) | expectCharacterAtPosition< 2, 'x' >( s ) + | expectCharacterAtPosition< 4, 'w' >( s ) | expectCharacterAtPosition< 5, 'x' >( s ) + | expectCharacterAtPosition< 6, 'r' >( s ) | expectCharacterAtPosition< 7, 'w' >( s ) + | expectCharacterAtPosition< 8, 'x' >( s ); +} + +} // namespace + +namespace Calamares +{ + +Permissions::Permissions() + : m_username() + , m_group() + , m_value( 0 ) + , m_valid( false ) +{ +} + +Permissions::Permissions( QString const& p ) + : Permissions() +{ + parsePermissions( p ); +} + +void +Permissions::parsePermissions( QString const& p ) +{ + + QStringList segments = p.split( ":" ); + + if ( segments.length() != 3 ) + { + m_valid = false; + return; + } + + if ( segments[ 0 ].isEmpty() || segments[ 1 ].isEmpty() ) + { + m_valid = false; + return; + } + + const auto octal = parseFileMode( segments[ 2 ] ); + if ( octal <= 0 ) + { + m_valid = false; + return; + } + else + { + m_value = octal; + } + + // We have exactly three segments and the third is valid octal, + // so we can declare the string valid and set the user and group names + m_valid = true; + m_username = segments[ 0 ]; + m_group = segments[ 1 ]; + + return; +} + +bool +Permissions::apply( const QString& path, int mode ) +{ + // We **don't** use QFile::setPermissions() here because it takes + // a Qt flags object that subtlely does not align with POSIX bits. + // The Qt flags are **hex** based, so 0x755 for rwxr-xr-x, while + // our integer (mode_t) stores **octal** based flags. + // + // Call chmod(2) directly, that's what Qt would be doing underneath + // anyway. + int r = chmod( path.toUtf8().constData(), mode_t( mode ) ); + if ( r ) + { + cDebug() << Logger::SubEntry << "Could not set permissions of" << path << "to" << QString::number( mode, 8 ); + } + return r == 0; +} + +bool +Permissions::apply( const QString& path, const Calamares::Permissions& p ) +{ + if ( !p.isValid() ) + { + return false; + } + bool r = apply( path, p.value() ); + if ( r ) + { + // We don't use chgrp(2) or chown(2) here because then we need to + // go through the users list (which one, target or source?) to get + // uid_t and gid_t values to pass to that system call. + // + // Do a lame cop-out and let the chown(8) utility do the heavy lifting. + if ( Calamares::System::runCommand( { "chown", p.username() + ':' + p.group(), path }, + std::chrono::seconds( 3 ) ) + .getExitCode() ) + { + r = false; + cDebug() << Logger::SubEntry << "Could not set owner of" << path << "to" + << ( p.username() + ':' + p.group() ); + } + } + if ( r ) + { + /* NOTUSED */ apply( path, p.value() ); + } + return r; +} + +///@brief Assumes an octal 3-digit (at most) value +static int +parseOctalFileMode( const QString& mode ) +{ + bool ok; + int octal = mode.toInt( &ok, 8 ); + if ( !ok ) + { + return -1; + } + if ( 0777 < octal ) + { + return -1; + } + if ( octal < 0 ) + { + return -1; + } + return octal; +} + +///@brief Checks for "rwx"-style modes, which must be 9 characters and start with a - or an r +static bool +isRWXMode( const QString& mode ) +{ + if ( mode.length() != 9 ) + { + return false; + } + if ( mode.startsWith( '-' ) || mode.startsWith( 'r' ) ) + { + return true; + } + return false; +} + +int +parseFileMode( const QString& mode ) +{ + if ( mode.startsWith( 'o' ) ) + { + return parseOctalFileMode( mode.mid( 1 ) ); + } + if ( isRWXMode( mode ) ) + { + return modeFromVerboseString( mode ); + } + + return parseOctalFileMode( mode ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/Permissions.h b/calamares/src/libcalamares/utils/Permissions.h new file mode 100644 index 0000000..02ffc9c --- /dev/null +++ b/calamares/src/libcalamares/utils/Permissions.h @@ -0,0 +1,113 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Scott Harvey + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#ifndef LIBCALAMARES_PERMISSIONS_H +#define LIBCALAMARES_PERMISSIONS_H + +#include "DllMacro.h" + +#include + +namespace Calamares +{ + +/** + * @brief Represents a :: + * + * The Permissions class takes a QString @p in the form of + * ::, checks it for validity, and makes the three + * components available indivdually. + */ +class DLLEXPORT Permissions +{ + +public: + /** @brief Constructor + * + * Splits the string @p at the colon (":") into separate elements for + * , , and (permissions), where is any + * value that can be parsed by parseFileMode() . One valid form + * is an **octal** integer. That is, "root:wheel:755" will give + * you an integer value of four-hundred-ninety-three (493), + * corresponding to the UNIX file permissions rwxr-xr-x, + * as one would expect from chmod and other command-line utilities. + */ + Permissions( QString const& p ); + + /// @brief Default constructor of an invalid Permissions. + Permissions(); + + /// @brief Was the Permissions object constructed from valid data? + bool isValid() const { return m_valid; } + /// @brief The user (first component, e.g. "root" in "root:wheel:755") + QString username() const { return m_username; } + /// @brief The group (second component, e.g. "wheel" in "root:wheel:755") + QString group() const { return m_group; } + /** @brief The value (file permission) as an integer. + * + * Bear in mind that input is in octal, but integers are just integers; + * naively printing them will get decimal results (e.g. 493 from the + * input of "root:wheel:755"). This is suitable to pass to apply(). + */ + int value() const { return m_value; } + /** @brief The value (file permission) as octal string + * + * This is suitable for passing to chmod-the-program, or for + * recreating the original Permissions string. + */ + QString octal() const { return QString::number( value(), 8 ); } + + /** @brief Sets the file-access @p mode of @p path + * + * Pass a path that is relative (or absolute) in the **host** system. + * + * @return @c true on success + */ + static bool apply( const QString& path, int mode ); + /** @brief Do both chmod and chown on @p path + * + * Note that interpreting user- and group- names for applying the + * permissions can be different between the host system and the target + * system; the target might not have a "live" user, for instance, and + * the host won't have the user-entered username for the installation. + * + * For this call, the names are interpreted in the **host** system. + * Pass a path that is relative (or absolute) in the **host** system. + * + * @return @c true on success of **both** operations + */ + static bool apply( const QString& path, const Permissions& p ); + /// Convenience method for apply(const QString&, const Permissions& ) + bool apply( const QString& path ) const { return apply( path, *this ); } + +private: + void parsePermissions( QString const& p ); + + QString m_username; + QString m_group; + int m_value; + bool m_valid; +}; + +/** + * @brief Parses a file-mode and returns it as an integer + * + * Returns -1 on error. + * + * Valid forms of @p mode are: + * - octal representation, with an optional leading 0 and at most three + * octal digits (e.g. 0755 or 644). + * - octal representation with a leading 'o' (letter) and at most three + * octal digits (e.g. o755 or o644). Use this in YAML where a string + * of digits would otherwise be interpreted as a (base-10) integer. + * - "rwx" representation with exactly 9 characters like the output of ls. + */ +DLLEXPORT int parseFileMode( const QString& mode ); + +} // namespace Calamares + +#endif // LIBCALAMARES_PERMISSIONS_H diff --git a/calamares/src/libcalamares/utils/PluginFactory.cpp b/calamares/src/libcalamares/utils/PluginFactory.cpp new file mode 100644 index 0000000..9f26a8a --- /dev/null +++ b/calamares/src/libcalamares/utils/PluginFactory.cpp @@ -0,0 +1,13 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "PluginFactory.h" + +CalamaresPluginFactory::~CalamaresPluginFactory() {} diff --git a/calamares/src/libcalamares/utils/PluginFactory.h b/calamares/src/libcalamares/utils/PluginFactory.h new file mode 100644 index 0000000..3c975ac --- /dev/null +++ b/calamares/src/libcalamares/utils/PluginFactory.h @@ -0,0 +1,113 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_PLUGINFACTORY_H +#define UTILS_PLUGINFACTORY_H + +#include "DllMacro.h" + +#include + +#define CalamaresPluginFactory_iid "io.calamares.PluginFactory" + +/** @brief Plugin factory for Calamares + * + * A Calamares plugin contains just one kind of plugin -- either + * a job, or a viewstep -- so the factory is straightforward. + * It gets a single CreateInstanceFunction and calls that; + * the function is set when registerPlugin() is called in a subclass. + * + */ +class DLLEXPORT CalamaresPluginFactory : public QObject +{ + Q_OBJECT +public: + explicit CalamaresPluginFactory() {} + ~CalamaresPluginFactory() override; + + typedef QObject* ( *CreateInstanceFunction )( QObject* ); + + template < class T > + T* create( QObject* parent = nullptr ) + { + auto* op = fn ? fn( parent ) : nullptr; + if ( !op ) + { + return nullptr; + } + T* tp = qobject_cast< T* >( op ); + if ( !tp ) + { + delete op; + } + return tp; + } + +protected: + CreateInstanceFunction fn = nullptr; +}; + +/** @brief declare a Calamares Plugin Factory + * + * There should be one declaration -- generally alongside the + * class definition for the Job or ViewStep that the plugin is + * going to provide, in the header -- and one definition -- in + * the corresponding implementation. + */ +#define CALAMARES_PLUGIN_FACTORY_DECLARATION( name ) \ + class name : public CalamaresPluginFactory \ + { \ + Q_OBJECT \ + Q_INTERFACES( CalamaresPluginFactory ) \ + Q_PLUGIN_METADATA( IID CalamaresPluginFactory_iid ) \ + public: \ + explicit name(); \ + ~name() override; \ + template < class T > \ + static QObject* createInstance( QObject* parent ) \ + { \ + return new T( parent ); \ + } \ + template < class T > \ + void registerPlugin() \ + { \ + fn = createInstance< T >; \ + } \ + }; + +/** @brief Define a Calamares Plugin Factory + * + * This should be done exactly once, generally in the translation + * unit containing the definitions for the main class of the plugin, + * either the Job or the ViewStep definitions. + * + * The @p name must match the name used in the declaration, while + * @p pluginRegistrations should be a single call to `registerPlugin()` + * where `T` is the type (subclass of Job or ViewStep) defined by the + * plugin, eg. + * + * ``` + * CALAMARES_PLUGIN_FACTORY_DEFINITION( MyPlugin, registerPlugin() ) + * ``` + * + * Leaving out the `()` will lead to generally-weird compiler warnings. + */ +#define CALAMARES_PLUGIN_FACTORY_DEFINITION( name, pluginRegistrations ) \ + name::name() \ + : CalamaresPluginFactory() \ + { \ + pluginRegistrations; \ + } \ + name::~name() {} + +Q_DECLARE_INTERFACE( CalamaresPluginFactory, CalamaresPluginFactory_iid ) + +#endif diff --git a/calamares/src/libcalamares/utils/RAII.h b/calamares/src/libcalamares/utils/RAII.h new file mode 100644 index 0000000..8d4a907 --- /dev/null +++ b/calamares/src/libcalamares/utils/RAII.h @@ -0,0 +1,112 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_RAII_H +#define UTILS_RAII_H + +#include +#include + +#include +#include + +/** @brief Convenience to zero out and deleteLater of any QObject-derived-class + * + * If, before destruction, preserve is set to @c true, then + * the object is "preserved", and not deleted at all. + */ +template < typename T > +struct cqDeleter +{ + T*& p; + bool preserve = false; + + ~cqDeleter() + { + static_assert( std::is_base_of< QObject, T >::value, "Not a QObject-class" ); + if ( !preserve ) + { + if ( p ) + { + p->deleteLater(); + } + p = nullptr; + } + } +}; + +/// @brief Blocks signals on a QObject until destruction +using cSignalBlocker = QSignalBlocker; + +/** @brief Writes a value on destruction to a pointed-to location. + * + * If the pointer is non-null, write the last-given-value if there + * is one to the pointed-to object. This is called the "then-value". + * + */ +template < typename T > +struct cScopedAssignment +{ + std::optional< T > m_value; + T* m_pointer; + + /** @brief Create a setter with no value set + * + * Until a value is set via operator=(), this pointer-setter + * will do nothing on destruction, leaving the pointed-to + * value unchanged. + */ + cScopedAssignment( T* p ) + : m_pointer( p ) + { + } + /** @brief Create a setter with a then-value already set + * + * This ensures that on destruction, the value @p v will be written; + * it is equivalent to assigning @p v immediately. The pointed-to + * value is **not** changed (until destruction). + */ + cScopedAssignment( T* p, T then ) + : m_value( then ) + , m_pointer( p ) + { + } + /** @brief Create a setter with a then-value and assign a new value now + * + * As above, but also assign @p now to the thing pointed-to. + */ + cScopedAssignment( T* p, T now, T then ) + : m_value( then ) + , m_pointer( p ) + { + if ( p ) + { + *p = now; + } + } + + ~cScopedAssignment() + { + if ( m_pointer && m_value.has_value() ) + { + *m_pointer = m_value.value(); + } + } + + const T& operator=( const T& then ) + { + m_value = then; + return then; + } +}; + +template < typename T > +cScopedAssignment( T p ) -> cScopedAssignment< decltype( *p ) >; +#endif diff --git a/calamares/src/libcalamares/utils/Retranslator.cpp b/calamares/src/libcalamares/utils/Retranslator.cpp new file mode 100644 index 0000000..cb71c4e --- /dev/null +++ b/calamares/src/libcalamares/utils/Retranslator.cpp @@ -0,0 +1,243 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Retranslator.h" + +#include "Settings.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" + +#include +#include +#include +#include + +namespace +{ + +static bool s_allowLocalTranslations = false; + +/** @brief Helper class for loading translations + * + * This is used by the loadSingletonTranslator() function to hand off + * work to translation-type specific code. + */ +struct TranslationLoader +{ + TranslationLoader( const QString& locale ) + : m_localeName( locale ) + { + } + + virtual ~TranslationLoader(); + /// @brief Loads @p translator with the specific translations of this type + virtual bool tryLoad( QTranslator* translator ) = 0; + + QString m_localeName; +}; + +/// @brief Loads translations for branding +struct BrandingLoader : public TranslationLoader +{ + BrandingLoader( const QString& locale, const QString& prefix ) + : TranslationLoader( locale ) + , m_prefix( prefix ) + { + } + + bool tryLoad( QTranslator* translator ) override; + + QString m_prefix; +}; + +/// @brief Loads regular Calamares translations (program text) +struct CalamaresLoader : public TranslationLoader +{ + using TranslationLoader::TranslationLoader; + bool tryLoad( QTranslator* translator ) override; +}; + +/// @brief Loads timezone name translations +struct TZLoader : public TranslationLoader +{ + using TranslationLoader::TranslationLoader; + bool tryLoad( QTranslator* translator ) override; +}; + +TranslationLoader::~TranslationLoader() {} + +bool +BrandingLoader::tryLoad( QTranslator* translator ) +{ + if ( m_prefix.isEmpty() ) + { + return false; + } + // This is working backwards against m_prefix containing both + // a path and a branding-name. Split it in path + branding-name. + const int lastDirSeparator = m_prefix.lastIndexOf( QDir::separator() ); + QString brandingTranslationsDirPath( m_prefix ); + brandingTranslationsDirPath.truncate( lastDirSeparator ); + QString filenameBase( m_prefix ); + filenameBase.remove( 0, lastDirSeparator + 1 ); + + if ( QDir( brandingTranslationsDirPath ).exists() ) + { + const QString fileName = QStringLiteral( "%1_%2" ).arg( filenameBase, m_localeName ); + cDebug() << Logger::SubEntry << "Loading" << fileName << "from" << brandingTranslationsDirPath; + if ( translator->load( fileName, brandingTranslationsDirPath ) ) + { + cDebug() << Logger::SubEntry << "Branding using locale:" << m_localeName; + return true; + } + else + { + cDebug() << Logger::SubEntry << "Branding no translation for" << m_localeName << "using default (en)"; + // TODO: this loads something completely different + return translator->load( m_prefix + "en" ); + } + } + return false; +} + +static bool +tryLoad( QTranslator* translator, const QString& prefix, const QString& localeName ) +{ + // In debug-mode, try loading from the current directory + if ( s_allowLocalTranslations && translator->load( prefix + localeName ) ) + { + cDebug() << Logger::SubEntry << "Loaded local translation" << prefix << localeName; + return true; + } + + // Or load from appDataDir -- often /usr/share/calamares -- subdirectory land/ + QDir localeData( Calamares::appDataDir() ); + if ( localeData.exists() + && translator->load( localeData.absolutePath() + QStringLiteral( "/lang/" ) + prefix + localeName ) ) + { + cDebug() << Logger::SubEntry << "Loaded appdata translation" << prefix << localeName; + return true; + } + + // Or from QRC (most common) + if ( translator->load( QStringLiteral( ":/lang/" ) + prefix + localeName ) ) + { + cDebug() << Logger::SubEntry << "Loaded QRC translation" << prefix << localeName; + return true; + } + else + { + cDebug() << Logger::SubEntry << "No translation for" << prefix << localeName << "using default (en)"; + return translator->load( QStringLiteral( ":/lang/" ) + prefix + QStringLiteral( "en" ) ); + } +} + +bool +CalamaresLoader::tryLoad( QTranslator* translator ) +{ + return ::tryLoad( translator, QStringLiteral( "calamares_" ), m_localeName ); +} + +bool +TZLoader::tryLoad( QTranslator* translator ) +{ + return ::tryLoad( translator, QStringLiteral( "tz_" ), m_localeName ); +} + +static void +loadSingletonTranslator( TranslationLoader&& loader, QTranslator*& translator_p ) +{ + if ( !translator_p ) + { + QTranslator* translator = new QTranslator(); + loader.tryLoad( translator ); + QCoreApplication::installTranslator( translator ); + translator_p = translator; + } + else + { + loader.tryLoad( translator_p ); + } +} + +} // namespace + +namespace Calamares +{ +static QTranslator* s_brandingTranslator = nullptr; +static QTranslator* s_translator = nullptr; +static QTranslator* s_tztranslator = nullptr; +static QString s_translatorLocaleName; + +void +installTranslator( const Calamares::Locale::Translation::Id& locale, const QString& brandingTranslationsPrefix ) +{ + s_translatorLocaleName = locale.name; + + loadSingletonTranslator( BrandingLoader( locale.name, brandingTranslationsPrefix ), s_brandingTranslator ); + loadSingletonTranslator( TZLoader( locale.name ), s_tztranslator ); + loadSingletonTranslator( CalamaresLoader( locale.name ), s_translator ); +} + +void +installTranslator() +{ + installTranslator( Calamares::Locale::Translation().id(), QString() ); +} + +Calamares::Locale::Translation::Id +translatorLocaleName() +{ + return { s_translatorLocaleName }; +} + +bool +loadTranslator( const Calamares::Locale::Translation::Id& locale, const QString& prefix, QTranslator* translator ) +{ + return ::tryLoad( translator, prefix, locale.name ); +} + +Retranslator::Retranslator( QObject* parent ) + : QObject( parent ) +{ +} + +bool +Retranslator::eventFilter( QObject* obj, QEvent* e ) +{ + if ( e->type() == QEvent::LanguageChange ) + { + emit languageChanged(); + } + // pass the event on to the base + return QObject::eventFilter( obj, e ); +} + +Retranslator* +Retranslator::instance() +{ + static Retranslator s_instance( nullptr ); + return &s_instance; +} + +void +Retranslator::attach( QObject* o, std::function< void() > f ) +{ + connect( instance(), &Retranslator::languageChanged, o, f ); + f(); +} + +void +setAllowLocalTranslation( bool allow ) +{ + s_allowLocalTranslations = allow; +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/Retranslator.h b/calamares/src/libcalamares/utils/Retranslator.h new file mode 100644 index 0000000..0075562 --- /dev/null +++ b/calamares/src/libcalamares/utils/Retranslator.h @@ -0,0 +1,145 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_RETRANSLATOR_H +#define UTILS_RETRANSLATOR_H + +#include "DllMacro.h" +#include "locale/Translation.h" + +#include +#include + +#include + +class QEvent; +class QLocale; +class QTranslator; + +namespace Calamares +{ +/** @brief changes the application language. + * @param locale the new locale (names as defined by Calamares). + * @param brandingTranslationsPrefix the branding path prefix, from Calamares::Branding. + */ +DLLEXPORT void installTranslator( const Calamares::Locale::Translation::Id& locale, + const QString& brandingTranslationsPrefix ); + +/** @brief Initializes the translations with the current system settings + */ +DLLEXPORT void installTranslator(); + +/** @brief The name of the (locale of the) most recently installed translator + * + * May return something different from the locale.name() of the + * QLocale passed in, because Calamares will munge some names and + * may remap translations. + */ +DLLEXPORT Calamares::Locale::Translation::Id translatorLocaleName(); + +/** @brief Loads translations into the given @p translator + * + * This function is not intended for general use: it is for those special + * cases where modules need their own translator / translations for data + * that is locale to the module. Tries to load a .qm from "sensible" + * locations, which are the same ones that installTranslator() would use. + * Takes local-translations into account. + * + * Note that @p prefix should end with an underscore '_' -- this function + * does not introduce one by itself. + * + * @returns @c true on success + */ +DLLEXPORT bool +loadTranslator( const Calamares::Locale::Translation::Id& locale, const QString& prefix, QTranslator* translator ); + +/** @brief Set @p allow to true to load translations from current dir. + * + * If false, (or never called) the translations are loaded only from + * system locations (the AppData dir) and from QRC (compiled in). + * Enable local translations to test translations stored in the + * current directory. + */ +DLLEXPORT void setAllowLocalTranslation( bool allow ); + +/** @brief Handles change-of-language events + * + * There is one single Retranslator object. Use `instance()` to get it. + * The top-level widget of the application should call + * `installEventFilter( Retranslator::instance() )` + * to set up event-handling for translation events. The Retranslator + * will emit signal `languageChanged()` if there is such an event. + * + * Normal consumers should not have to use the Retranslator directly, + * but use the macros `CALAMARES_RETRANSLATE*` to set things up + * in code -- the macros will connect to the Retranslator's signals. + */ +class DLLEXPORT Retranslator : public QObject +{ + Q_OBJECT +public: + /// @brief Gets the global (single) Retranslator object + static Retranslator* instance(); + + /// @brief Helper function for attaching lambdas + static void attach( QObject* o, std::function< void( void ) > f ); + +signals: + void languageChanged(); + +protected: + bool eventFilter( QObject* obj, QEvent* e ) override; + +private: + explicit Retranslator( QObject* parent ); +}; + +} // namespace Calamares + +/** @brief Call code for this object when language changes + * + * @p body should be a code block (it does not need braces) that can be wrapped + * up as a lambda. When the language changes, the lambda is called. Note that + * this macro should be used in constructors or other code that is run only + * once, since otherwise you will end up with multiple calls to @p body. + * + * NOTE: unlike plain QObject::connect(), the body is **also** called + * immediately after setting up the connection. This allows + * setup and translation code to be mixed together. + */ +#define CALAMARES_RETRANSLATE( body ) Calamares::Retranslator::attach( this, [ = ] { body } ) +/** @brief Call code for the given object (widget) when language changes + * + * This is identical to CALAMARES_RETRANSLATE, except the @p body is called + * for the given object, not this object. + * + * NOTE: unlike plain QObject::connect(), the body is **also** called + * immediately after setting up the connection. This allows + * setup and translation code to be mixed together. + */ +#define CALAMARES_RETRANSLATE_FOR( object, body ) Calamares::Retranslator::attach( object, [ = ] { body } ) +/** @brief Call a slot in this object when language changes + * + * Given a slot (in method-function-pointer notation), call that slot when the + * language changes. This is shorthand for connecting the Retranslator's + * signal to the given slot. + * + * NOTE: unlike plain QObject::connect(), the slot is **also** called + * immediately after setting up the connection. This allows + * setup and translation code to be mixed together. + */ +#define CALAMARES_RETRANSLATE_SLOT( slotfunc ) \ + do \ + { \ + connect( Calamares::Retranslator::instance(), &Calamares::Retranslator::languageChanged, this, slotfunc ); \ + ( this->*slotfunc )(); \ + } while ( false ) + +#endif diff --git a/calamares/src/libcalamares/utils/Runner.cpp b/calamares/src/libcalamares/utils/Runner.cpp new file mode 100644 index 0000000..f7872a7 --- /dev/null +++ b/calamares/src/libcalamares/utils/Runner.cpp @@ -0,0 +1,248 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Runner.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "utils/Logger.h" + +#include + +/** @brief Descend from directory, always relative + * + * If @p subdir begins with a "/" or "../" or "./" those are stripped + * until none are left, then changes @p directory into that + * subdirectory. + * + * Returns @c false if the @p subdir doesn't make sense. + */ +STATICTEST bool +relativeChangeDirectory( QDir& directory, const QString& subdir ) +{ + const QString rootPath = directory.absolutePath(); + const QString concatenatedPath = rootPath + '/' + subdir; + const QString relPath = QDir::cleanPath( concatenatedPath ); + + if ( !relPath.startsWith( rootPath ) ) + { + cWarning() << "Relative path" << subdir << "escapes from" << rootPath; + return false; + } + + return directory.cd( relPath ); +} + +STATICTEST std::pair< bool, QDir > +calculateWorkingDirectory( Calamares::Utils::RunLocation location, const QString& directory ) +{ + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + + if ( location == Calamares::Utils::RunLocation::RunInTarget ) + { + if ( !gs || !gs->contains( "rootMountPoint" ) ) + { + cWarning() << "No rootMountPoint in global storage, while RunInTarget is specified"; + return std::make_pair( false, QDir() ); + } + + QDir rootMountPoint( gs->value( "rootMountPoint" ).toString() ); + if ( !rootMountPoint.exists() ) + { + cWarning() << "rootMountPoint points to a dir which does not exist"; + return std::make_pair( false, QDir() ); + } + + if ( !directory.isEmpty() ) + { + + if ( !relativeChangeDirectory( rootMountPoint, directory ) || !rootMountPoint.exists() ) + { + cWarning() << "Working directory" << directory << "does not exist in target"; + return std::make_pair( false, QDir() ); + } + } + return std::make_pair( true, rootMountPoint ); // Now changed to subdir + } + else + { + QDir root; + if ( !directory.isEmpty() ) + { + root = QDir::root(); + + if ( !relativeChangeDirectory( root, directory ) || !root.exists() ) + { + cWarning() << "Working directory" << directory << "does not exist in host"; + return std::make_pair( false, QDir() ); + } + } + return std::make_pair( true, root ); // Now changed to subdir + } +} + +namespace Calamares +{ +namespace Utils +{ + +Runner::Runner() {} + +} // namespace Utils +} // namespace Calamares + +Calamares::Utils::Runner::Runner( const QStringList& command ) +{ + setCommand( command ); +} + +Calamares::Utils::Runner::~Runner() {} + +Calamares::Utils::ProcessResult +Calamares::Utils::Runner::run() +{ + if ( m_command.isEmpty() ) + { + cWarning() << "Cannot run an empty program list"; + return ProcessResult::Code::FailedToStart; + } + + auto [ ok, workingDirectory ] = calculateWorkingDirectory( m_location, m_directory ); + if ( !ok || !workingDirectory.exists() ) + { + // Warnings have already been printed + return ProcessResult::Code::NoWorkingDirectory; + } + + QProcess process; + // Make the process run in "C" locale so we don't get issues with translation + { + auto env = QProcessEnvironment::systemEnvironment(); + env.insert( "LC_ALL", "C" ); + // No guarantees that host settings for /tmp/ make sense in target + if ( m_location == RunLocation::RunInTarget ) + { + env.remove( "TEMP" ); + env.remove( "TEMPDIR" ); + env.remove( "TMP" ); + env.remove( "TMPDIR" ); + } + process.setProcessEnvironment( env ); + } + process.setProcessChannelMode( QProcess::MergedChannels ); + if ( !m_directory.isEmpty() ) + { + process.setWorkingDirectory( workingDirectory.absolutePath() ); + } + if ( m_location == RunLocation::RunInTarget ) + { + process.setProgram( "chroot" ); + process.setArguments( QStringList { workingDirectory.absolutePath() } << m_command ); + } + else + { + process.setProgram( "env" ); + process.setArguments( m_command ); + } + + if ( m_output ) + { + connect( &process, + &QProcess::readyReadStandardOutput, + [ this, &process ]() + { + do + { + QString s = process.readLine(); + if ( !s.isEmpty() ) + { + Q_EMIT this->output( s ); + } + } while ( process.canReadLine() ); + } ); + } + + cDebug() << Logger::SubEntry << "Running" << Logger::RedactedCommand( m_command ); + process.start(); + if ( !process.waitForStarted() ) + { + cWarning() << "Process" << m_command.first() << "failed to start" << process.error(); + return ProcessResult::Code::FailedToStart; + } + + if ( !m_input.isEmpty() ) + { + process.write( m_input.toLocal8Bit() ); + } + process.closeWriteChannel(); + + if ( !process.waitForFinished( m_timeout > std::chrono::seconds::zero() + ? ( static_cast< int >( std::chrono::milliseconds( m_timeout ).count() ) ) + : -1 ) ) + { + cWarning() << "Process" << m_command.first() << "timed out after" << m_timeout.count() << "ms." + << Logger::NoQuote << "Output so far:\n" + << process.readAllStandardOutput(); + return ProcessResult::Code::TimedOut; + } + + QString output = m_output ? QString() : QString::fromLocal8Bit( process.readAllStandardOutput() ).trimmed(); + if ( m_output ) + { + // Try to read trailing output, if any + do + { + output = process.readLine(); + if ( !output.isEmpty() ) + { + Q_EMIT this->output( output ); + } + } while ( !output.isEmpty() ); + output = process.readAllStandardOutput(); + if ( !output.isEmpty() ) + { + cWarning() << "Some process output left-over"; + Q_EMIT this->output( output ); + } + } + + if ( process.exitStatus() == QProcess::CrashExit ) + { + cWarning() << "Process" << m_command.first() << "crashed." << Logger::NoQuote << "Output so far:\n" << output; + return ProcessResult::Code::Crashed; + } + + auto r = process.exitCode(); + const bool showDebug = ( !Calamares::Settings::instance() ) || ( Calamares::Settings::instance()->debugMode() ); + if ( r == 0 ) + { + if ( showDebug && !output.isEmpty() ) + { + cDebug() << Logger::SubEntry << "Finished. Exit code:" << r << Logger::NoQuote << "output:\n" << output; + } + } + else // if ( r != 0 ) + { + if ( !output.isEmpty() ) + { + cDebug() << Logger::SubEntry << "Target cmd:" << Logger::RedactedCommand( m_command ) << "Exit code:" << r + << Logger::NoQuote << "output:\n" + << output; + } + else + { + cDebug() << Logger::SubEntry << "Target cmd:" << Logger::RedactedCommand( m_command ) << "Exit code:" << r + << "(no output)"; + } + } + return ProcessResult( r, output ); +} diff --git a/calamares/src/libcalamares/utils/Runner.h b/calamares/src/libcalamares/utils/Runner.h new file mode 100644 index 0000000..aa80876 --- /dev/null +++ b/calamares/src/libcalamares/utils/Runner.h @@ -0,0 +1,135 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_RUNNER_H +#define UTILS_RUNNER_H + +#include "System.h" + +#include +#include +#include + +#include +#include +#include + +namespace Calamares +{ +namespace Utils +{ + +using RunLocation = Calamares::System::RunLocation; +using ProcessResult = Calamares::ProcessResult; + +/** @brief A Runner wraps a process and handles running it and processing output + * + * This is basically a QProcess, but handles both running in the + * host system (through env(1)) or in the target (by calling chroot(8)). + * It has an output signal that handles output one line at a time + * (unlike QProcess that lets you do the buffering yourself). + * This output processing is only enabled if you do so explicitly. + * + * Use the set*() methods to configure the runner. + * + * If you call enableOutputProcessing(), then you can connect to + * the output() signal to receive each line (including trailing newline!). + * + * Processes are always run with LC_ALL and LANG set to "C". + */ +class DLLEXPORT Runner : public QObject +{ + Q_OBJECT + +public: + /** @brief Create an empty runner + * + * This is a runner with no commands, nothing; call set*() methods + * to configure it. + */ + Runner(); + /** @brief Create a runner with a specified command + * + * Equivalent to Calamares::Utils::Runner::Runner() followed by + * calling setCommand(). + */ + Runner( const QStringList& command ); + virtual ~Runner() override; + + Runner& setCommand( const QStringList& command ) + { + m_command = command; + return *this; + } + Runner& setLocation( RunLocation r ) + { + m_location = r; + return *this; + } + Runner& setWorkingDirectory( const QDir& directory ) + { + m_directory = directory.absolutePath(); + return *this; + } + Runner& setWorkingDirectory( const QString& directory ) + { + m_directory = directory; + return *this; + } + Runner& setTimeout( std::chrono::seconds timeout ) + { + m_timeout = timeout; + return *this; + } + Runner& setInput( const QString& input ) + { + m_input = input; + return *this; + } + Runner& setOutputProcessing( bool enable ) + { + m_output = enable; + return *this; + } + + Runner& enableOutputProcessing() + { + m_output = true; + return *this; + } + + ProcessResult run(); + /** @brief The executable (argv[0]) that this runner will run + * + * This is the first element of the command; it does not include + * env(1) or chroot(8) which are injected when actually running + * the command. + */ + QString executable() const { return m_command.isEmpty() ? QString() : m_command.first(); } + +signals: + void output( QString line ); + +private: + // What to run, and where. + QStringList m_command; + QString m_directory; + RunLocation m_location { RunLocation::RunInHost }; + + // Settings for when it actually runs + QString m_input; + std::chrono::milliseconds m_timeout { 0 }; + bool m_output = false; +}; + +} // namespace Utils +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/String.cpp b/calamares/src/libcalamares/utils/String.cpp new file mode 100644 index 0000000..88583f5 --- /dev/null +++ b/calamares/src/libcalamares/utils/String.cpp @@ -0,0 +1,250 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2013-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from Tomahawk, portions: + * SPDX-FileCopyrightText: 2010-2011 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2010-2011 Leo Franchi + * SPDX-FileCopyrightText: 2010-2012 Jeff Mitchell + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "String.h" +#include "Logger.h" + +#include + +namespace Calamares +{ +namespace String +{ +QString +removeDiacritics( const QString& string ) +{ + // *INDENT-OFF* + // clang-format off + // Map these extended-Latin characters to ASCII; keep the + // layout so that one line in *diacriticLetters* corresponds + // to one line of replacements in *noDiacriticLetters*. + static const QString diacriticLetters = QString::fromUtf8( + "ŠŒŽšœžŸ¥µÀ" + "ÁÂÃÄÅÆÇÈÉÊ" + "ËÌÍÎÏÐÑÒÓÔ" + "ÕÖØÙÚÛÜÝßà" + "áâãäåæçèéê" + "ëìíîïðñòóô" + "õöøùúûüýÿÞ" + "þČčĆćĐ𩹮" + "žŞşĞğİıȚțȘ" + "șĂăŐőŰűŘřĀ" + "āĒēĪīŌōŪūŢ" + "ţẀẁẂẃŴŵŶŷĎ" + "ďĚěŇňŤťŮůŔ" + "ॹĘꣳŃńŚ" + "śŹźŻż" + ); + static const QStringList noDiacriticLetters = { + "S", "OE", "Z", "s", "oe", "z", "Y", "Y", "u", "A", + "A", "A", "A", "A", "AA", "AE", "C", "E", "E", "E", + "E", "I", "I", "I", "I", "D", "N", "O", "O", "O", + "O", "E", "OE", "U", "U", "U", "E", "Y", "s", "a", + "a", "a", "a", "e", "aa", "ae", "c", "e", "e", "e", + "e", "i", "i", "i", "i", "d", "n", "o", "o", "o", + "o", "e", "oe", "u", "u", "u", "e", "y", "y", "TH", + "th", "C", "c", "C", "c", "DJ", "dj", "S", "s", "Z", + "z", "S", "s", "G", "g", "I", "i", "T", "t", "S", + "s", "A", "a", "O", "o", "U", "u", "R", "r", "A", + "a", "E", "e", "I", "i", "O", "o", "U", "u", "T", + "t", "W", "w", "W", "w", "W", "w", "Y", "y", "D", + "d", "E", "e", "N", "n", "T", "t", "U", "u", "R", + "r", "A", "a", "E", "e", "L", "l", "N", "n", "S", + "s", "Z", "z", "Z", "z" + }; + // clang-format on + // *INDENT-ON* + + QString output; + for ( const QChar& c : string ) + { + int i = diacriticLetters.indexOf( c ); + if ( i < 0 ) + { + output.append( c ); + } + else + { + QString replacement = noDiacriticLetters[ i ]; + output.append( replacement ); + } + } + + return output; +} + +// Function Calamares::obscure based on KStringHandler::obscure, +// part of KDElibs by KDE, file kstringhandler.cpp. +// Original copyright statement follows. +/* This file is part of the KDE libraries + Copyright (C) 1999 Ian Zepp (icszepp@islc.net) + Copyright (C) 2006 by Dominic Battre + Copyright (C) 2006 by Martin Pool + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ +QString +obscure( const QString& string ) +{ + QString result; + const QChar* unicode = string.unicode(); + for ( int i = 0; i < string.length(); ++i ) + // yes, no typo. can't encode ' ' or '!' because + // they're the unicode BOM. stupid scrambling. stupid. + { + result += ( unicode[ i ].unicode() <= 0x21 ) ? unicode[ i ] : QChar( 0x1001F - unicode[ i ].unicode() ); + } + return result; +} + +QString +truncateMultiLine( const QString& string, LinesStartEnd lines, CharCount chars ) +{ + const char NEWLINE = '\n'; + const int maxLines = lines.atStart + lines.atEnd; + if ( maxLines < 1 ) + { + QString shorter( string ); + shorter.truncate( chars.total ); + return shorter; + } + + const int physicalLinesInString = string.count( NEWLINE ); + const int logicalLinesInString = physicalLinesInString + ( string.endsWith( NEWLINE ) ? 0 : 1 ); + if ( ( string.length() <= chars.total ) && ( logicalLinesInString <= maxLines ) ) + { + return string; + } + + QString front, back; + if ( physicalLinesInString >= maxLines ) + { + int from = -1; + for ( int i = 0; i < lines.atStart; ++i ) + { + from = string.indexOf( NEWLINE, from + 1 ); + if ( from < 0 ) + { + // That's strange, we counted at least maxLines newlines before + break; + } + } + if ( from > 0 ) + { + front = string.left( from + 1 ); + } + + int lastNewLine = -1; + int lastCount = string.endsWith( NEWLINE ) ? -1 : 0; + for ( auto i = string.rbegin(); i != string.rend() && lastCount < lines.atEnd; ++i ) + { + if ( *i == NEWLINE ) + { + ++lastCount; + lastNewLine = int( i - string.rbegin() ); + } + } + if ( ( lastNewLine >= 0 ) && ( lastCount >= lines.atEnd ) ) + { + back = string.right( lastNewLine ); + } + } + else + { + // We have: <= maxLines and longer than chars.total, so: + // - carve out a chunk in the middle, based a little on + // how the balance of atStart and atEnd is + const int charsToChop = string.length() - chars.total; + if ( charsToChop < 1 ) + { + // That's strange, again + return string; + } + const int startPortion = charsToChop * lines.atStart / maxLines; + const int endPortion = charsToChop * lines.atEnd / maxLines; + front = string.left( string.length() / 2 - startPortion ); + back = string.right( string.length() / 2 - endPortion ); + } + + if ( front.length() + back.length() <= chars.total ) + { + return front + back; + } + + // We need to cut off some bits, preserving whether there are + // newlines present at the end of the string. Go case-by-case: + if ( !front.isEmpty() && back.isEmpty() ) + { + // Truncate towards the front + bool needsNewline = front.endsWith( NEWLINE ); + front.truncate( chars.total ); + if ( !front.endsWith( NEWLINE ) && needsNewline ) + { + front.append( NEWLINE ); + } + return front; + } + if ( front.isEmpty() && !back.isEmpty() ) + { + // Truncate towards the tail + return back.right( chars.total ); + } + // Both are non-empty, so nibble away at both of them + front.truncate( chars.total / 2 ); + if ( !front.endsWith( NEWLINE ) && physicalLinesInString > 0 ) + { + front.append( NEWLINE ); + } + return front + back.right( chars.total / 2 ); +} + +void +removeLeading( QString& string, QChar c ) +{ + int count = 0; + while ( string.length() > count && string[ count ] == c ) + { + count++; + } + string.remove( 0, count ); +} + +void +removeTrailing( QString& string, QChar c ) +{ + int lastIndex = string.length(); + while ( lastIndex > 0 && string[ lastIndex - 1 ] == c ) + { + lastIndex--; + } + string.remove( lastIndex, string.length() ); +} + +} // namespace String +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/String.h b/calamares/src/libcalamares/utils/String.h new file mode 100644 index 0000000..cf942f1 --- /dev/null +++ b/calamares/src/libcalamares/utils/String.h @@ -0,0 +1,112 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2013-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from Tomahawk, portions: + * SPDX-FileCopyrightText: 2010-2011 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2010-2011 Leo Franchi + * SPDX-FileCopyrightText: 2010-2012 Jeff Mitchell + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_STRING_H +#define UTILS_STRING_H + +#include "DllMacro.h" + +#include + +/* Qt 5.14 changed the API to QString::split(), adding new overloads + * that take a different enum, then Qt 5.15 deprecated the old ones. + * To avoid overly-many warnings related to the API change, introduce + * Calamares-specific constants that pull from the correct enum. + */ +constexpr static const auto SplitSkipEmptyParts = Qt::SkipEmptyParts; + +constexpr static const auto SplitKeepEmptyParts = Qt::KeepEmptyParts; + +namespace Calamares +{ +/** + * @brief The Calamares::String namespace + * + * This namespace contains functions related to string-handling, + * string-expansion, etc. + */ +namespace String +{ +/** + * @brief removeDiacritics replaces letters with diacritics and ligatures with + * alternative forms and digraphs. + * @param string the string to transform. + * @return the output string with plain characters. + */ +DLLEXPORT QString removeDiacritics( const QString& string ); + +/** + * @brief obscure is a bidirectional obfuscation function, from KStringHandler. + * @param string the input string. + * @return the obfuscated string. + */ +DLLEXPORT QString obscure( const QString& string ); + +/** @brief Parameter for counting lines at beginning and end of string + * + * This is used by truncateMultiLine() to indicate how many lines from + * the beginning and how many from the end should be kept. + */ +struct LinesStartEnd +{ + int atStart = 0; + int atEnd = 0; +}; + +/** @brief Parameter for counting characters in truncateMultiLine() + */ +struct CharCount +{ + int total = 0; +}; + +/** @brief Truncate a string to some reasonable length for display + * + * Keep the first few, or last few (or both) lines of a possibly lengthy + * message @p string and reduce it to a displayable size (e.g. for + * pop-up windows that display the message). If the message is longer + * than @p chars, then characters are removed from the front (if + * @p lines.atStart is zero) or end (if @p lines.atEnd is zero) or in the middle + * (if both are nonzero). + * + * Asking for 0 lines will make this behave like QString::truncate(). + * + * @param string the input string. + * @param lines number of lines to preserve. + * @param chars maximum number of characters in the returned string. + * @return a string built from parts of the input string. + */ +DLLEXPORT QString truncateMultiLine( const QString& string, + LinesStartEnd lines = LinesStartEnd { 3, 5 }, + CharCount chars = CharCount { 812 } ); + +/** @brief Remove all @p c at the beginning of @p string + * + * Modifies the @p string in-place. If @p c is not the first character + * of @p string, the string is left unchanged; otherwise the first character + * is removed and the process repeats. + */ +DLLEXPORT void removeLeading( QString& string, QChar c ); +/** @brief Remove all @p c at the end of @p string + * + * Like removeLeading(), but at the end of the string. + */ +DLLEXPORT void removeTrailing( QString& string, QChar c ); + +} // namespace String +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/StringExpander.cpp b/calamares/src/libcalamares/utils/StringExpander.cpp new file mode 100644 index 0000000..321ae21 --- /dev/null +++ b/calamares/src/libcalamares/utils/StringExpander.cpp @@ -0,0 +1,87 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "StringExpander.h" +#include "Logger.h" + +namespace Calamares +{ +namespace String +{ + +struct DictionaryExpander::Private +{ + QHash< QString, QString > dictionary; + QStringList missing; +}; + +DictionaryExpander::DictionaryExpander() + : KWordMacroExpander( '$' ) + , d( std::make_unique< Private >() ) +{ +} + +DictionaryExpander::DictionaryExpander( Calamares::String::DictionaryExpander&& other ) + : KWordMacroExpander( other.escapeChar() ) + , d( std::move( other.d ) ) +{ +} + +DictionaryExpander::~DictionaryExpander() {} + +void +DictionaryExpander::insert( const QString& key, const QString& value ) +{ + d->dictionary.insert( key, value ); +} + +void +DictionaryExpander::clearErrors() +{ + d->missing.clear(); +} + +bool +DictionaryExpander::hasErrors() const +{ + return !d->missing.isEmpty(); +} + +QStringList +DictionaryExpander::errorNames() const +{ + return d->missing; +} + +QString +DictionaryExpander::expand( QString s ) +{ + clearErrors(); + expandMacros( s ); + return s; +} + +bool +DictionaryExpander::expandMacro( const QString& str, QStringList& ret ) +{ + if ( d->dictionary.contains( str ) ) + { + ret << d->dictionary[ str ]; + return true; + } + else + { + d->missing << str; + return false; + } +} + +} // namespace String +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/StringExpander.h b/calamares/src/libcalamares/utils/StringExpander.h new file mode 100644 index 0000000..fa46bf9 --- /dev/null +++ b/calamares/src/libcalamares/utils/StringExpander.h @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_STRINGEXPANDER_H +#define UTILS_STRINGEXPANDER_H + +#include "DllMacro.h" + +#include + +#include +#include + +#include + +namespace Calamares +{ +namespace String +{ + +/** @brief Expand variables in a string against a dictionary. + * + * This class provides a convenience API for building up a dictionary + * and using it to expand strings. Use the `expand()` method to + * do standard word-based expansion with `$` as macro-symbol. + * + * Unlike straight-up `KMacroExpander::expandMacros()`, this + * provides an API to find out which variables were missing + * from the dictionary during expansion. Use `hasErrors()` and + * `errorNames()` to find out which variables those were. + * + * Call `clearErrors()` to reset the stored errors. Calling + * `expand()` implicitly clears the errors before starting + * a new expansion, as well. + */ +class DLLEXPORT DictionaryExpander : public KWordMacroExpander +{ +public: + DictionaryExpander(); + DictionaryExpander( DictionaryExpander&& ); + virtual ~DictionaryExpander() override; + + void insert( const QString& key, const QString& value ); + /** @brief As insert(), but supports method-chaining. + * + */ + DictionaryExpander& add( const QString& key, const QString& value ) + { + insert( key, value ); + return *this; + } + + void clearErrors(); + bool hasErrors() const; + QStringList errorNames() const; + + QString expand( QString s ); + +protected: + virtual bool expandMacro( const QString& str, QStringList& ret ) override; + +private: + struct Private; + std::unique_ptr< Private > d; +}; + +} // namespace String +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/System.cpp b/calamares/src/libcalamares/utils/System.cpp new file mode 100644 index 0000000..dd098af --- /dev/null +++ b/calamares/src/libcalamares/utils/System.cpp @@ -0,0 +1,345 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "System.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Runner.h" +#include "utils/Logger.h" + +#include +#include +#include + +#ifdef Q_OS_LINUX +#include +#endif + +#ifdef Q_OS_FREEBSD +// clang-format off +// these includes need to stay in-order (that's a FreeBSD thing) +#include +#include +// clang-format on +#endif + +namespace Calamares +{ + +System* System::s_instance = nullptr; + +System::System( bool doChroot, QObject* parent ) + : QObject( parent ) + , m_doChroot( doChroot ) +{ + Q_ASSERT( !s_instance ); + s_instance = this; + if ( !doChroot && Calamares::JobQueue::instance() && Calamares::JobQueue::instance()->globalStorage() ) + { + Calamares::JobQueue::instance()->globalStorage()->insert( "rootMountPoint", "/" ); + } +} + +System::~System() {} + +System* +System::instance() +{ + if ( !s_instance ) + { + cError() << "No Calamares system-object has been created."; + cDebug() << Logger::SubEntry << "using a bogus instance instead."; + return new System( true, nullptr ); + } + return s_instance; +} + +ProcessResult +System::runCommand( System::RunLocation location, + const QStringList& args, + const QString& workingPath, + const QString& stdInput, + std::chrono::seconds timeoutSec ) +{ + Calamares::Utils::Runner r( args ); + r.setLocation( location ).setInput( stdInput ).setTimeout( timeoutSec ).setWorkingDirectory( workingPath ); + return r.run(); +} + +/// @brief Cheap check if a path is absolute. +static inline bool +isAbsolutePath( const QString& path ) +{ + return path.startsWith( '/' ); +} + +QString +System::targetPath( const QString& path ) const +{ + if ( doChroot() ) + { + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + + if ( !gs || !gs->contains( "rootMountPoint" ) ) + { + cWarning() << "No rootMountPoint in global storage, cannot name target file" << path; + return QString(); + } + + QString root = gs->value( "rootMountPoint" ).toString(); + return isAbsolutePath( path ) ? ( root + path ) : ( root + '/' + path ); + } + else + { + return isAbsolutePath( path ) ? path : ( QStringLiteral( "/" ) + path ); + } +} + +CreationResult +System::createTargetFile( const QString& path, const QByteArray& contents, WriteMode mode ) const +{ + QString completePath = targetPath( path ); + if ( completePath.isEmpty() ) + { + cWarning() << "No target path for" << path; + return CreationResult( CreationResult::Code::Invalid ); + } + + QFile f( completePath ); + if ( ( mode == WriteMode::KeepExisting ) && f.exists() ) + { + cWarning() << "Target file" << completePath << "already exists"; + return CreationResult( CreationResult::Code::AlreadyExists ); + } + + QIODevice::OpenMode m + = ( mode == WriteMode::KeepExisting ? QIODevice::NewOnly : QIODevice::WriteOnly ) | QIODevice::Truncate; + + if ( !f.open( m ) ) + { + cWarning() << "Could not open target file" << completePath; + return CreationResult( CreationResult::Code::Failed ); + } + + auto written = f.write( contents ); + if ( written != contents.size() ) + { + f.close(); + f.remove(); + cWarning() << "Short write (" << written << "out of" << contents.size() << "bytes) to" << completePath; + return CreationResult( CreationResult::Code::Failed ); + } + + f.close(); + return CreationResult( QFileInfo( f ).canonicalFilePath() ); +} + +QStringList +System::readTargetFile( const QString& path ) const +{ + const QString completePath = targetPath( path ); + if ( completePath.isEmpty() ) + { + return QStringList(); + } + + QFile f( completePath ); + if ( !f.open( QIODevice::ReadOnly ) ) + { + return QStringList(); + } + + QTextStream in( &f ); + QStringList l; + while ( !in.atEnd() ) + { + l << in.readLine(); + } + return l; +} + +void +System::removeTargetFile( const QString& path ) const +{ + if ( !isAbsolutePath( path ) ) + { + cWarning() << "Will not remove non-absolute path" << path; + return; + } + QString target = targetPath( path ); + if ( !target.isEmpty() ) + { + QFile::remove( target ); + } + // If it was empty, a warning was already printed +} + +bool +System::createTargetDirs( const QString& path ) const +{ + if ( !isAbsolutePath( path ) ) + { + cWarning() << "Will not create basedirs for non-absolute path" << path; + return false; + } + + QString target = targetPath( path ); + if ( target.isEmpty() ) + { + // If it was empty, a warning was already printed + return false; + } + + QString root = Calamares::JobQueue::instance()->globalStorage()->value( "rootMountPoint" ).toString(); + if ( root.isEmpty() ) + { + return false; + } + + QDir d( root ); + if ( !d.exists() ) + { + cWarning() << "Root mountpoint" << root << "does not exist."; + return false; + } + return d.mkpath( target ); // This re-does everything starting from the **host** / +} + +bool +System::createTargetParentDirs( const QString& filePath ) const +{ + return createTargetDirs( QFileInfo( filePath ).dir().path() ); +} + +QPair< quint64, qreal > +System::getTotalMemoryB() const +{ +#ifdef Q_OS_LINUX + struct sysinfo i; + int r = sysinfo( &i ); + + if ( r ) + { + return qMakePair( 0, 0.0 ); + } + + return qMakePair( quint64( i.mem_unit ) * quint64( i.totalram ), 1.1 ); +#elif defined( Q_OS_FREEBSD ) + unsigned long memsize; + size_t s = sizeof( memsize ); + + int r = sysctlbyname( "vm.kmem_size", &memsize, &s, NULL, 0 ); + if ( r ) + { + return qMakePair( 0, 0.0 ); + } + + return qMakePair( memsize, 1.01 ); +#else + return qMakePair( 0, 0.0 ); // Unsupported +#endif +} + +QString +System::getCpuDescription() const +{ + QString model; + +#ifdef Q_OS_LINUX + QFile file( "/proc/cpuinfo" ); + if ( file.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + while ( !file.atEnd() ) + { + QByteArray line = file.readLine(); + if ( line.startsWith( "model name" ) && ( line.indexOf( ':' ) > 0 ) ) + { + model = QString::fromLatin1( line.right( line.length() - line.indexOf( ':' ) ) ); + break; + } + } + } +#elif defined( Q_OS_FREEBSD ) + // This would use sysctl "hw.model", which has a string value +#endif + return model.simplified(); +} + +quint64 +System::getTotalDiskB() const +{ + return 0; +} + +bool +System::doChroot() const +{ + return m_doChroot; +} + +Calamares::JobResult +ProcessResult::explainProcess( int ec, const QString& command, const QString& output, std::chrono::seconds timeout ) +{ + using Calamares::JobResult; + + if ( ec == 0 ) + { + return JobResult::ok(); + } + + QString outputMessage = output.isEmpty() + ? QCoreApplication::translate( "ProcessResult", "\nThere was no output from the command." ) + : ( QCoreApplication::translate( "ProcessResult", "\nOutput:\n" ) + output ); + + if ( ec == static_cast< int >( ProcessResult::Code::Crashed ) ) + { //Crash! + return JobResult::error( + QCoreApplication::translate( "ProcessResult", "External command crashed." ), + QCoreApplication::translate( "ProcessResult", "Command %1 crashed." ).arg( command ) + + outputMessage ); + } + + if ( ec == static_cast< int >( ProcessResult::Code::FailedToStart ) ) + { + return JobResult::error( + QCoreApplication::translate( "ProcessResult", "External command failed to start." ), + QCoreApplication::translate( "ProcessResult", "Command %1 failed to start." ).arg( command ) ); + } + + if ( ec == static_cast< int >( ProcessResult::Code::NoWorkingDirectory ) ) + { + return JobResult::error( + QCoreApplication::translate( "ProcessResult", "Internal error when starting command." ), + QCoreApplication::translate( "ProcessResult", "Bad parameters for process job call." ) ); + } + + if ( ec == static_cast< int >( ProcessResult::Code::TimedOut ) ) + { + return JobResult::error( + QCoreApplication::translate( "ProcessResult", "External command failed to finish." ), + QCoreApplication::translate( "ProcessResult", "Command %1 failed to finish in %2 seconds." ) + .arg( command ) + .arg( timeout.count() ) + + outputMessage ); + } + + //Any other exit code + return JobResult::error( + QCoreApplication::translate( "ProcessResult", "External command finished with errors." ), + QCoreApplication::translate( "ProcessResult", "Command %1 finished with exit code %2." ) + .arg( command ) + .arg( ec ) + + outputMessage ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/System.h b/calamares/src/libcalamares/utils/System.h new file mode 100644 index 0000000..6b6eba1 --- /dev/null +++ b/calamares/src/libcalamares/utils/System.h @@ -0,0 +1,365 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef UTILS_CALAMARESUTILSSYSTEM_H +#define UTILS_CALAMARESUTILSSYSTEM_H + +#include "DllMacro.h" + +#include "Job.h" + +#include +#include +#include + +#include + +namespace Calamares +{ +class ProcessResult : public QPair< int, QString > +{ +public: + enum class Code : int + { + Crashed = -1, // Must match special return values from QProcess + FailedToStart = -2, // Must match special return values from QProcess + NoWorkingDirectory = -3, + TimedOut = -4 + }; + + /** @brief Implicit one-argument constructor has no output, only a return code */ + ProcessResult( Code r ) + : QPair< int, QString >( static_cast< int >( r ), QString() ) + { + } + ProcessResult( int r, QString s ) + : QPair< int, QString >( r, s ) + { + } + + int getExitCode() const { return first; } + QString getOutput() const { return second; } + + /** @brief Explain a typical external process failure. + * + * @param errorCode Return code from runCommand() or similar + * (negative values get special explanation). The member + * function uses the exit code stored in the ProcessResult + * @param output (error) output from the command, used when there is + * an error to report (exit code > 0). The member + * function uses the output stored in the ProcessResult. + * @param command String or split-up string of the command + * that was invoked. + * @param timeout Timeout passed to the process runner, for explaining + * error code -4 (timeout). + */ + static DLLEXPORT Calamares::JobResult + explainProcess( int errorCode, const QString& command, const QString& output, std::chrono::seconds timeout ); + + /// @brief Convenience wrapper for explainProcess() + inline Calamares::JobResult explainProcess( const QString& command, std::chrono::seconds timeout ) const + { + return explainProcess( getExitCode(), command, getOutput(), timeout ); + } + + /// @brief Convenience wrapper for explainProcess() + inline Calamares::JobResult explainProcess( const QStringList& command, std::chrono::seconds timeout ) const + { + return explainProcess( getExitCode(), command.join( ' ' ), getOutput(), timeout ); + } +}; + +/** @brief The result of a create*() action, for status + * + * A CreationResult has a status field, can be converted to bool + * (true only on success) and can report the full pathname of + * the thing created if it was successful. + */ +class CreationResult : public QPair< int, QString > +{ +public: + enum class Code : int + { + // These are "not failed", but only OK is a success + OK = 0, + AlreadyExists = 1, + // These are "failed" + Invalid = -1, + Failed = -2 + }; + + CreationResult( Code r ) + : QPair< int, QString >( static_cast< int >( r ), QString() ) + { + } + explicit CreationResult( const QString& path ) + : QPair< int, QString >( 0, path ) + { + } + + Code code() const { return static_cast< Code >( first ); } + QString path() const { return second; } + + bool failed() const { return first < 0; } + operator bool() const { return first == 0; } +}; + +/** + * @brief The System class is a singleton with utility functions that perform + * system-specific operations. + */ +class DLLEXPORT System : public QObject +{ + Q_OBJECT +public: + /** + * @brief System the constructor. Only call this once in a Calamares instance. + * @param doChroot set to true if all external commands should run in the + * target system chroot, otherwise false to run everything on the current system. + * @param parent the QObject parent. + */ + explicit System( bool doChroot, QObject* parent = nullptr ); + ~System() override; + + static System* instance(); + + /** (Typed) Boolean describing where a particular command should be run, + * whether in the host (live) system or in the (chroot) target system. + */ + enum class RunLocation + { + RunInHost, + RunInTarget + }; + + /** @brief Runs a command in the host or the target (select explicitly) + * + * @param location whether to run in the host or the target + * @param args the command with arguments, as a string list. + * @param workingPath the current working directory for the QProcess + * call (optional). + * @param stdInput the input string to send to the running process as + * standard input (optional). + * @param timeoutSec the timeout after which the process will be + * killed (optional, default is 0 i.e. no timeout). + * + * @returns the program's exit code and its output (if any). Special + * exit codes (which will never have any output) are: + * Crashed = QProcess crash + * FailedToStart = QProcess cannot start + * NoWorkingDirectory = bad arguments + * TimedOut = QProcess timeout + */ + static DLLEXPORT ProcessResult runCommand( RunLocation location, + const QStringList& args, + const QString& workingPath = QString(), + const QString& stdInput = QString(), + std::chrono::seconds timeoutSec = std::chrono::seconds( 0 ) ); + + /** @brief Convenience wrapper for runCommand() in the host + * + * Runs the given command-line @p args in the **host** in the current direcory + * with no input, and the given @p timeoutSec for completion. + */ + static inline ProcessResult runCommand( const QStringList& args, std::chrono::seconds timeoutSec ) + { + return runCommand( RunLocation::RunInHost, args, QString(), QString(), timeoutSec ); + } + + /** @brief Convenience wrapper for runCommand(). + * + * Runs the command in the location specified through the boolean + * doChroot(), which is what you usually want for running commands + * during installation. + */ + inline ProcessResult targetEnvCommand( const QStringList& args, + const QString& workingPath = QString(), + const QString& stdInput = QString(), + std::chrono::seconds timeoutSec = std::chrono::seconds( 0 ) ) + { + return runCommand( + m_doChroot ? RunLocation::RunInTarget : RunLocation::RunInHost, args, workingPath, stdInput, timeoutSec ); + } + + /** @brief Convenience wrapper for targetEnvCommand() which returns only the exit code */ + inline int targetEnvCall( const QStringList& args, + const QString& workingPath = QString(), + const QString& stdInput = QString(), + std::chrono::seconds timeoutSec = std::chrono::seconds( 0 ) ) + { + return targetEnvCommand( args, workingPath, stdInput, timeoutSec ).first; + } + + /** @brief Convenience wrapper for targetEnvCommand() which returns only the exit code */ + inline int targetEnvCall( const QString& command, + const QString& workingPath = QString(), + const QString& stdInput = QString(), + std::chrono::seconds timeoutSec = std::chrono::seconds( 0 ) ) + { + return targetEnvCall( QStringList { command }, workingPath, stdInput, timeoutSec ); + } + + /** @brief Convenience wrapper for targetEnvCommand() which returns only the exit code + * + * Places the called program's output in the @p output string. + */ + int targetEnvOutput( const QStringList& args, + QString& output, + const QString& workingPath = QString(), + const QString& stdInput = QString(), + std::chrono::seconds timeoutSec = std::chrono::seconds( 0 ) ) + { + auto r = targetEnvCommand( args, workingPath, stdInput, timeoutSec ); + output = r.second; + return r.first; + } + + /** @brief Convenience wrapper for targetEnvCommand() which returns only the exit code + * + * Places the called program's output in the @p output string. + */ + inline int targetEnvOutput( const QString& command, + QString& output, + const QString& workingPath = QString(), + const QString& stdInput = QString(), + std::chrono::seconds timeoutSec = std::chrono::seconds( 0 ) ) + { + return targetEnvOutput( QStringList { command }, output, workingPath, stdInput, timeoutSec ); + } + + /** @brief Gets a path to a file in the target system, from the host. + * + * @param path Path to the file; this is interpreted + * from the root of the target system (whatever that may be, + * but / in the chroot, or / in OEM modes). + * + * @return The complete path to the target file, from + * the root of the host system, or empty on failure. + * + * For instance, during installation where the target root is + * mounted on /tmp/calamares-something, asking for targetPath("/etc/passwd") + * will give you /tmp/calamares-something/etc/passwd. + * + * No attempt is made to canonicalize anything, since paths might not exist. + */ + DLLEXPORT QString targetPath( const QString& path ) const; + + enum class WriteMode + { + KeepExisting, + Overwrite + }; + + /** @brief Create a (small-ish) file in the target system. + * + * @param path Path to the file; this is interpreted + * from the root of the target system (whatever that may be, + * but / in the chroot, or / in OEM modes). + * @param contents Actual content of the file. + * + * If the target already exists: + * - returns AlreadyExists as a result (and does not overwrite), + * - **unless** @p mode is set to Overwrite, then it tries writing as + * usual and will not return AlreadyExists. + * + * @return The complete canonical path to the target file from the + * root of the host system, or empty on failure. (Here, it is + * possible to be canonical because the file exists). + */ + DLLEXPORT CreationResult createTargetFile( const QString& path, + const QByteArray& contents, + WriteMode mode = WriteMode::KeepExisting ) const; + + /** @brief Remove a file from the target system. + * + * @param path Path to the file; this is interpreted from the root + * of the target system (@see targetPath()). + * + * Does no error checking to see if the target file was really removed. + */ + DLLEXPORT void removeTargetFile( const QString& path ) const; + + /** @brief Reads a file from the target system. + * + * @param path Path to the file; this is interpreted from the root of + * the target system (@see targetPath()). + * + * Does no error checking, and returns an empty list if the file does + * not exist. + * + * NOTE: This function is now basically the same as QFile::readAll(), + * splitting into lines, but Calamares may need to change + * permissions or raise privileges to actually read the file, + * which is why there is an API. + * + * NOTE: Since this buffers the whole file in memory, reading big files + * is not recommended. + */ + DLLEXPORT QStringList readTargetFile( const QString& path ) const; + + /** @brief Ensure that the directory @p path exists + * + * @param path a full pathname to a desired directory. + * + * All the directory components including the last path component are + * created, as needed. Returns true on success. + * + * @see QDir::mkpath + */ + DLLEXPORT bool createTargetDirs( const QString& path ) const; + + /** @brief Convenience to create parent directories of a file path. + * + * Creates all the parent directories until the last + * component of @p filePath . @see createTargetDirs() + */ + DLLEXPORT bool createTargetParentDirs( const QString& filePath ) const; + + /** + * @brief getTotalMemoryB returns the total main memory, in bytes. + * + * Since it is difficult to get the RAM memory size exactly -- either + * by reading information from the DIMMs, which may fail on virtual hosts + * or from asking the kernel, which doesn't report some memory areas -- + * this returns a pair of guessed-size (in bytes) and a "guesstimate factor" + * which says how good the guess is. Generally, assume the *real* memory + * available is size * guesstimate. + * + * If nothing can be found, returns a 0 size and 0 guesstimate. + * + * @return size, guesstimate-factor + */ + DLLEXPORT QPair< quint64, qreal > getTotalMemoryB() const; + + /** + * @brief getCpuDescription returns a string describing the CPU. + * + * Returns the value of the "model name" line in /proc/cpuinfo. + */ + DLLEXPORT QString getCpuDescription() const; + + /** + * @brief getTotalDiskB returns the total disk attached, in bytes. + * + * If nothing can be found, returns a 0. + */ + DLLEXPORT quint64 getTotalDiskB() const; + + DLLEXPORT bool doChroot() const; + +private: + static System* s_instance; + + bool m_doChroot; +}; + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/TestPaths.cpp b/calamares/src/libcalamares/utils/TestPaths.cpp new file mode 100644 index 0000000..649c2ea --- /dev/null +++ b/calamares/src/libcalamares/utils/TestPaths.cpp @@ -0,0 +1,260 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Entropy.h" +#include "Logger.h" +#include "System.h" +#include "UMask.h" +#include "Yaml.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include +#include + +class TestPaths : public QObject +{ + Q_OBJECT +public: + TestPaths() {} + ~TestPaths() override {} + +private Q_SLOTS: + void initTestCase(); + void init(); + void cleanupTestCase(); + + void testCreationResult(); + void testTargetPath(); + void testCreateTarget(); + void testCreateTargetExists(); + void testCreateTargetOverwrite(); + void testCreateTargetBasedirs(); + +private: + Calamares::System* m_system = nullptr; // Points to singleton instance, not owned + Calamares::GlobalStorage* m_gs = nullptr; +}; + +static const char testFile[] = "/calamares-testcreate"; +static const char absFile[] = "/tmp/calamares-testcreate"; // With rootMountPoint prepended + +void +TestPaths::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + // Ensure we have a system object, expect it to be a "bogus" one + Calamares::System* system = Calamares::System::instance(); + QVERIFY( system ); + QVERIFY( system->doChroot() ); + + // Ensure we have a system-wide GlobalStorage with /tmp as root + if ( !Calamares::JobQueue::instance() ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + } + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); + + m_system = system; + m_gs = gs; +} + +void +TestPaths::cleanupTestCase() +{ + QFile::remove( absFile ); +} + +void +TestPaths::init() +{ + cDebug() << "Setting rootMountPoint"; + m_gs->insert( "rootMountPoint", "/tmp" ); +} + +void +TestPaths::testCreationResult() +{ + using Code = Calamares::CreationResult::Code; + + for ( auto c : { Code::OK, Code::AlreadyExists, Code::Failed, Code::Invalid } ) + { + auto r = Calamares::CreationResult( c ); + QVERIFY( r.path().isEmpty() ); + QCOMPARE( r.path(), QString() ); + // Get a warning from Clang if we're not covering everything + switch ( r.code() ) + { + case Code::OK: + QVERIFY( !r.failed() ); + QVERIFY( r ); + break; + case Code::AlreadyExists: + QVERIFY( !r.failed() ); + QVERIFY( !r ); + break; + case Code::Failed: + case Code::Invalid: + QVERIFY( r.failed() ); + QVERIFY( !r ); + break; + } + } + + QString path( "/etc/os-release" ); + auto r = Calamares::CreationResult( path ); + QVERIFY( !r.failed() ); + QVERIFY( r ); + QCOMPARE( r.code(), Code::OK ); + QCOMPARE( r.path(), path ); +} + +void +TestPaths::testTargetPath() +{ + // Paths mapped normally + QCOMPARE( m_system->targetPath( "/etc/calamares" ), QStringLiteral( "/tmp/etc/calamares" ) ); + QCOMPARE( m_system->targetPath( "//etc//calamares" ), + QStringLiteral( "/tmp//etc//calamares" ) ); // extra / are not cleaned up + QCOMPARE( m_system->targetPath( "etc/calamares" ), QStringLiteral( "/tmp/etc/calamares" ) ); // relative to root + + // Weird Paths + QCOMPARE( m_system->targetPath( QString() ), QStringLiteral( "/tmp/" ) ); + + // Now break GS + m_gs->remove( "rootMountPoint" ); + QCOMPARE( m_system->targetPath( QString() ), QString() ); // Without root, no path +} + +void +TestPaths::testCreateTarget() +{ + auto r = m_system->createTargetFile( testFile, "Hello" ); + QVERIFY( !r.failed() ); + QVERIFY( r ); + QCOMPARE( r.path(), QString( absFile ) ); // Success + + QFileInfo fi( absFile ); + QVERIFY( fi.exists() ); + QCOMPARE( fi.size(), 5 ); + + m_system->removeTargetFile( testFile ); + QFileInfo fi2( absFile ); // fi caches information + QVERIFY( !fi2.exists() ); +} + +struct GSRollback +{ + GSRollback( const QString& key ) + : m_key( key ) + , m_value( Calamares::JobQueue::instance()->globalStorage()->value( key ) ) + { + } + ~GSRollback() { Calamares::JobQueue::instance()->globalStorage()->insert( m_key, m_value ); } + QString m_key; + QVariant m_value; +}; + +void +TestPaths::testCreateTargetExists() +{ + static const char ltestFile[] = "cala-test-world"; + GSRollback g( QStringLiteral( "rootMountPoint" ) ); + + QTemporaryDir d; + d.setAutoRemove( true ); + Calamares::JobQueue::instance()->globalStorage()->insert( QStringLiteral( "rootMountPoint" ), d.path() ); + + QVERIFY( QFileInfo( d.path() ).exists() ); + auto r = m_system->createTargetFile( ltestFile, "Hello" ); + QVERIFY( r ); + QVERIFY( r.path().endsWith( QString( ltestFile ) ) ); + QCOMPARE( QFileInfo( d.filePath( QString( ltestFile ) ) ).size(), 5 ); + + r = m_system->createTargetFile( ltestFile, "Goodbye" ); + QVERIFY( !r.failed() ); // It didn't fail! + QVERIFY( !r ); // But not unqualified success, either + + QVERIFY( r.path().isEmpty() ); + QCOMPARE( QFileInfo( d.filePath( QString( ltestFile ) ) ).size(), 5 ); // Unchanged! +} + +void +TestPaths::testCreateTargetOverwrite() +{ + static const char ltestFile[] = "cala-test-world"; + GSRollback g( QStringLiteral( "rootMountPoint" ) ); + + QTemporaryDir d; + d.setAutoRemove( true ); + Calamares::JobQueue::instance()->globalStorage()->insert( QStringLiteral( "rootMountPoint" ), d.path() ); + + QVERIFY( QFileInfo( d.path() ).exists() ); + auto r = m_system->createTargetFile( ltestFile, "Hello" ); + QVERIFY( r ); + QVERIFY( r.path().endsWith( QString( ltestFile ) ) ); + QCOMPARE( QFileInfo( d.filePath( QString( ltestFile ) ) ).size(), 5 ); + + r = m_system->createTargetFile( ltestFile, "Goodbye", Calamares::System::WriteMode::KeepExisting ); + QVERIFY( !r.failed() ); // It didn't fail! + QVERIFY( !r ); // But not unqualified success, either + + QVERIFY( r.path().isEmpty() ); + QCOMPARE( QFileInfo( d.filePath( QString( ltestFile ) ) ).size(), 5 ); // Unchanged! + + r = m_system->createTargetFile( ltestFile, "Goodbye", Calamares::System::WriteMode::Overwrite ); + QVERIFY( !r.failed() ); // It didn't fail! + QVERIFY( r ); // Total success + + QVERIFY( r.path().endsWith( QString( ltestFile ) ) ); + QCOMPARE( QFileInfo( d.filePath( QString( ltestFile ) ) ).size(), 7 ); +} + +struct DirRemover +{ + DirRemover( const QString& base, const QString& dir ) + : m_base( base ) + , m_dir( dir ) + { + } + ~DirRemover() { QDir( m_base ).rmpath( m_dir ); } + + bool exists() const { return QDir( m_base ).exists( m_dir ); } + + QString m_base, m_dir; +}; + +void +TestPaths::testCreateTargetBasedirs() +{ + { + DirRemover dirrm( "/tmp", "var/lib/dbus" ); + QVERIFY( m_system->createTargetDirs( "/" ) ); + QVERIFY( m_system->createTargetDirs( "/var/lib/dbus" ) ); + QVERIFY( QFile( "/tmp/var/lib/dbus" ).exists() ); + QVERIFY( dirrm.exists() ); + } + QVERIFY( !QFile( "/tmp/var/lib/dbus" ).exists() ); + + // QFileInfo.dir() behaves even when things don't exist + QCOMPARE( QFileInfo( "/tmp/var/lib/dbus/bogus" ).dir().path(), QStringLiteral( "/tmp/var/lib/dbus" ) ); +} + +QTEST_GUILESS_MAIN( TestPaths ) + +#include "utils/moc-warnings.h" + +#include "TestPaths.moc" diff --git a/calamares/src/libcalamares/utils/Tests.cpp b/calamares/src/libcalamares/utils/Tests.cpp new file mode 100644 index 0000000..e1a298a --- /dev/null +++ b/calamares/src/libcalamares/utils/Tests.cpp @@ -0,0 +1,1468 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "CommandList.h" +#include "Entropy.h" +#include "Logger.h" +#include "Permissions.h" +#include "RAII.h" +#include "Runner.h" +#include "String.h" +#include "StringExpander.h" +#include "System.h" +#include "Traits.h" +#include "UMask.h" +#include "Variant.h" +#include "Yaml.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" + +#include + +#include + +#include + +#include +#include +#include + +class LibCalamaresTests : public QObject +{ + Q_OBJECT +public: + LibCalamaresTests(); + ~LibCalamaresTests() override; + +private Q_SLOTS: + void initTestCase(); + void testDebugLevels(); + + void testLoadSaveYaml(); // Just settings.conf + void testLoadSaveYamlExtended(); // Do a find() in the src dir + + /** @section Test running commands and command-expansion. */ + void testCommands(); + void testCommandExpansion_data(); + void testCommandExpansion(); // See also shellprocess tests + void testCommandConstructors(); + void testCommandConstructorsYAML(); + void testCommandRunning(); + void testCommandTimeout(); + void testCommandVerbose(); + + /** @section Test that all the UMask objects work correctly. */ + void testUmask(); + void testPermissions(); + + /** @section Tests the entropy functions. */ + void testEntropy(); + void testPrintableEntropy(); + void testOddSizedPrintable(); + + /** @section Tests the RAII bits. */ + void testPointerSetter(); + + /** @section Tests the Traits bits. */ + void testTraits(); + + /** @section Testing the variants-methods */ + void testVariantStringListCode(); + void testVariantStringListYAMLDashed(); + void testVariantStringListYAMLBracketed(); + + /** @section Test smart string truncation. */ + void testStringTruncation(); + void testStringTruncationShorter(); + void testStringTruncationDegenerate(); + void testStringRemoveLeading_data(); + void testStringRemoveLeading(); + void testStringRemoveTrailing_data(); + void testStringRemoveTrailing(); + + /** @section Test String expansion. */ + void testStringMacroExpander_data(); + void testStringMacroExpander(); // The KF5::CoreAddons bits + + /** @section Test Runner directory-manipulation. */ + void testRunnerDirs(); + void testCalculateWorkingDirectory(); + void testRunnerOutput(); + + /** @section Test file-functions */ + void testReadWriteFile(); + +private: + void recursiveCompareMap( const QVariantMap& a, const QVariantMap& b, int depth ); +}; + +LibCalamaresTests::LibCalamaresTests() {} + +LibCalamaresTests::~LibCalamaresTests() {} + +void +LibCalamaresTests::initTestCase() +{ + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + + if ( !gs ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + gs = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + } + QVERIFY( gs ); +} + +void +LibCalamaresTests::testDebugLevels() +{ + Logger::setupLogLevel( Logger::LOG_DISABLE ); + + QCOMPARE( Logger::logLevel(), static_cast< unsigned int >( Logger::LOG_DISABLE ) ); + + for ( unsigned int level = 0; level <= Logger::LOGVERBOSE; ++level ) + { + Logger::setupLogLevel( level ); + QCOMPARE( Logger::logLevel(), level ); + QVERIFY( Logger::logLevelEnabled( level ) ); + + for ( unsigned int xlevel = 0; xlevel <= Logger::LOGVERBOSE; ++xlevel ) + { + QCOMPARE( Logger::logLevelEnabled( xlevel ), xlevel <= level ); + } + } +} + +void +LibCalamaresTests::testLoadSaveYaml() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + QFile f( "settings.conf" ); + // Find the nearest settings.conf to read + for ( unsigned int up = 0; !f.exists() && ( up < 4 ); ++up ) + { + f.setFileName( QString( "../" ) + f.fileName() ); + } + cDebug() << QDir().absolutePath() << f.fileName() << f.exists(); + QVERIFY( f.exists() ); + + auto map = Calamares::YAML::load( f.fileName() ); + QVERIFY( map.contains( "sequence" ) ); + QCOMPARE( Calamares::typeOf( map[ "sequence" ] ), Calamares::ListVariantType ); + + // The source-repo example `settings.conf` has a show and an exec phase + auto sequence = map[ "sequence" ].toList(); + cDebug() << "Loaded example `settings.conf` sequence:"; + for ( const auto& v : sequence ) + { + cDebug() << Logger::SubEntry << v; + QCOMPARE( Calamares::typeOf( v ), Calamares::MapVariantType ); + QVERIFY( v.toMap().contains( "show" ) || v.toMap().contains( "exec" ) ); + } + + Calamares::YAML::save( "out.yaml", map ); + + auto other_map = Calamares::YAML::load( "out.yaml" ); + Calamares::YAML::save( "out2.yaml", other_map ); + QCOMPARE( map, other_map ); + + QFile::remove( "out.yaml" ); + QFile::remove( "out2.yaml" ); +} + +static QStringList +findConf( const QDir& d ) +{ + QStringList mine; + if ( d.exists() ) + { + QString path = d.absolutePath(); + path.append( d.separator() ); + for ( const auto& confname : d.entryList( { "*.conf" } ) ) + { + mine.append( path + confname ); + } + for ( const auto& subdirname : d.entryList( QDir::AllDirs | QDir::NoDotAndDotDot ) ) + { + QDir subdir( d ); + subdir.cd( subdirname ); + mine.append( findConf( subdir ) ); + } + } + return mine; +} + +void +LibCalamaresTests::recursiveCompareMap( const QVariantMap& a, const QVariantMap& b, int depth ) +{ + cDebug() << "Comparing depth" << depth << a.count() << b.count(); + QCOMPARE( a.keys(), b.keys() ); + for ( const auto& k : a.keys() ) + { + cDebug() << Logger::SubEntry << k; + const auto& av = a[ k ]; + const auto& bv = b[ k ]; + + if ( av.typeName() != bv.typeName() ) + { + cDebug() << Logger::SubEntry << "a type" << av.typeName() << av; + cDebug() << Logger::SubEntry << "b type" << bv.typeName() << bv; + } + QCOMPARE( av.typeName(), bv.typeName() ); + if ( av.canConvert< QVariantMap >() ) + { + recursiveCompareMap( av.toMap(), bv.toMap(), depth + 1 ); + } + else + { + QCOMPARE( av, bv ); + } + } +} + +void +LibCalamaresTests::testLoadSaveYamlExtended() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + bool loaded_ok; + for ( const auto& confname : findConf( QDir( "../src" ) ) ) + { + loaded_ok = true; + cDebug() << "Testing" << confname; + auto map = Calamares::YAML::load( confname, &loaded_ok ); + QVERIFY( loaded_ok ); + QVERIFY( Calamares::YAML::save( "out.yaml", map ) ); + auto othermap = Calamares::YAML::load( "out.yaml", &loaded_ok ); + QVERIFY( loaded_ok ); + QCOMPARE( map.keys(), othermap.keys() ); + recursiveCompareMap( map, othermap, 0 ); + QCOMPARE( map, othermap ); + } + QFile::remove( "out.yaml" ); +} + +void +LibCalamaresTests::testCommands() +{ + using Calamares::System; + auto r = System::runCommand( System::RunLocation::RunInHost, { "/bin/ls", "/tmp" } ); + + QVERIFY( r.getExitCode() == 0 ); + + QTemporaryFile tf( "/tmp/calamares-test-XXXXXX" ); + QVERIFY( tf.open() ); + QVERIFY( !tf.fileName().isEmpty() ); + + QFileInfo tfn( tf.fileName() ); + QVERIFY( !r.getOutput().contains( tfn.fileName() ) ); + + // Run ls again, now that the file exists + r = System::runCommand( System::RunLocation::RunInHost, { "/bin/ls", "/tmp" } ); + QVERIFY( r.getOutput().contains( tfn.fileName() ) ); + + // .. and without a working directory set, assume builddir != /tmp + r = System::runCommand( System::RunLocation::RunInHost, { "/bin/ls" } ); + QVERIFY( !r.getOutput().contains( tfn.fileName() ) ); + + r = System::runCommand( System::RunLocation::RunInHost, { "/bin/ls" }, "/tmp" ); + QVERIFY( r.getOutput().contains( tfn.fileName() ) ); +} + +void +LibCalamaresTests::testCommandExpansion_data() +{ + QTest::addColumn< QString >( "command" ); + QTest::addColumn< QString >( "expected" ); + + QTest::newRow( "empty " ) << QString() << QString(); + QTest::newRow( "ls " ) << QStringLiteral( "ls" ) << QStringLiteral( "ls" ); + QTest::newRow( "$USER " ) << QStringLiteral( "chmod $USER" ) << QStringLiteral( "chmod alice" ); + QTest::newRow( "${USER}" ) << QStringLiteral( "chmod ${USER}" ) << QStringLiteral( "chmod alice" ); + QTest::newRow( "gs-user" ) << QStringLiteral( "chmod ${gs[username]}" ) << QStringLiteral( "chmod alice" ); + QTest::newRow( "gs-* " ) << QStringLiteral( + "${gs[username]} has ${gs[branding.bootloader]} ${gs[branding.ducks]} ducks" ) + << QStringLiteral( "alice has found 3 ducks" ); + // QStringList does not expand + QTest::newRow( "gs-list" ) << QStringLiteral( "colors ${gs[branding.color]}" ) + << QStringLiteral( "colors ${gs[branding.color]}" ); +} + +void +LibCalamaresTests::testCommandExpansion() +{ + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); + gs->insert( QStringLiteral( "username" ), QStringLiteral( "alice" ) ); + + QVariantMap m; + m.insert( QStringLiteral( "bootloader" ), QStringLiteral( "found" ) ); + m.insert( QStringLiteral( "ducks" ), 3 ); + m.insert( QStringLiteral( "color" ), QStringList { "green", "red" } ); + gs->insert( QStringLiteral( "branding" ), m ); + + QFETCH( QString, command ); + QFETCH( QString, expected ); + Calamares::CommandLine c( command, std::chrono::seconds( 0 ) ); + Calamares::CommandLine e = c.expand(); + + QCOMPARE( c.command(), command ); + QCOMPARE( e.command(), expected ); +} + +void +LibCalamaresTests::testCommandConstructors() +{ + const QString command( "do this" ); + Calamares::CommandLine c0( command ); + + QCOMPARE( c0.command(), command ); + QCOMPARE( c0.timeout(), Calamares::CommandLine::TimeoutNotSet() ); + QVERIFY( c0.environment().isEmpty() ); + + const QStringList env { "-la", "/tmp" }; + Calamares::CommandLine c1( command, env, Calamares::CommandLine::TimeoutNotSet() ); + + QCOMPARE( c1.command(), command ); + QCOMPARE( c1.timeout(), Calamares::CommandLine::TimeoutNotSet() ); + QVERIFY( !c1.environment().isEmpty() ); + QCOMPARE( c1.environment().count(), 2 ); + QCOMPARE( c1.environment(), env ); +} + +void +LibCalamaresTests::testCommandConstructorsYAML() +{ + QTemporaryFile f; + QVERIFY( f.open() ); + f.write( R"(--- +commands: + - one-string-command + - command: only-command + - command: with-timeout + timeout: 12 + - command: all-three + timeout: 20 + environment: + - PATH=/USER + - DISPLAY=:0 + )" ); + f.close(); + bool ok = false; + QVariantMap m = Calamares::YAML::load( f.fileName(), &ok ); + + QVERIFY( ok ); + QCOMPARE( m.count(), 1 ); + QCOMPARE( m[ "commands" ].toList().count(), 4 ); + + { +#ifdef THIS_DOES_NOT_COMPILE_AND_THATS_THE_POINT + // Take care! The second parameter is a bool, so "3" here would + // mean "true", except the int overload is deleted to prevent just that. + Calamares::CommandList cmds( m[ "commands" ], 3 ); + // .. and there's no conversion from std::chrono::duration to bool either. + Calamares::CommandList cmds( m[ "commands" ], std::chrono::seconds( 3 ) ); +#endif + Calamares::CommandList cmds( m[ "commands" ], true ); + QCOMPARE( cmds.defaultTimeout(), std::chrono::seconds( 10 ) ); + // But the 4 commands are there anyway + QCOMPARE( cmds.count(), 4 ); + QCOMPARE( cmds.at( 0 ).command(), QString( "one-string-command" ) ); + QCOMPARE( cmds.at( 0 ).environment(), QStringList() ); + QCOMPARE( cmds.at( 0 ).timeout(), Calamares::CommandLine::TimeoutNotSet() ); + QCOMPARE( cmds.at( 1 ).command(), QString( "only-command" ) ); + QCOMPARE( cmds.at( 2 ).command(), QString( "with-timeout" ) ); + QCOMPARE( cmds.at( 2 ).environment(), QStringList() ); + QCOMPARE( cmds.at( 2 ).timeout(), std::chrono::seconds( 12 ) ); + + QStringList expectedEnvironment = { "PATH=/USER", "DISPLAY=:0" }; + QCOMPARE( cmds.at( 3 ).command(), QString( "all-three" ) ); + QCOMPARE( cmds.at( 3 ).environment(), expectedEnvironment ); + QCOMPARE( cmds.at( 3 ).timeout(), std::chrono::seconds( 20 ) ); + } + + { + Calamares::CommandList cmds( m[ "commands" ], true, std::chrono::seconds( 3 ) ); + QCOMPARE( cmds.defaultTimeout(), std::chrono::seconds( 3 ) ); + QCOMPARE( cmds.at( 0 ).timeout(), Calamares::CommandLine::TimeoutNotSet() ); + QCOMPARE( cmds.at( 2 ).timeout(), std::chrono::seconds( 12 ) ); + } +} + +void +LibCalamaresTests::testCommandRunning() +{ + + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + tempRoot.setAutoRemove( false ); + + const QString testExecutable = tempRoot.filePath( "example.sh" ); + const QString testFile = tempRoot.filePath( "example.txt" ); + + { + QFile f( testExecutable ); + QVERIFY( f.open( QIODevice::WriteOnly ) ); + f.write( "#! /bin/sh\necho \"$calamares_test_variable\"\n" ); + f.close(); + Calamares::Permissions::apply( testExecutable, 0755 ); + } + + const QString echoCommand = testExecutable + QStringLiteral( " > " ) + testFile; + + // Without an environment, the variable echoed in the example + // executable is empty, and we write a single newline to stdout, + // which is redirected to testFile. + { + Calamares::CommandList l( false ); // no chroot + Calamares::CommandLine c( echoCommand, {}, std::chrono::seconds( 2 ) ); + l.push_back( c ); + + const auto r = l.run(); + QVERIFY( bool( r ) ); + + QCOMPARE( QFileInfo( testFile ).size(), 1 ); // single newline + } + + // With an environment, echoes the value of the variable and a newline + { + const QString world = QStringLiteral( "Hello world" ); + Calamares::CommandList l( false ); // no chroot + Calamares::CommandLine c( + echoCommand, + { QStringLiteral( "calamares_test_variable=" ) + QChar( '"' ) + world + QChar( '"' ) }, + std::chrono::seconds( 2 ) ); + l.push_back( c ); + + const auto r = l.run(); + QVERIFY( bool( r ) ); + + QCOMPARE( QFileInfo( testFile ).size(), world.length() + 1 ); // plus newline + QFile f( testFile ); + QVERIFY( f.open( QIODevice::ReadOnly ) ); + QCOMPARE( f.readAll(), world + QChar( '\n' ) ); + } + + + tempRoot.setAutoRemove( true ); +} + +void +LibCalamaresTests::testCommandTimeout() +{ + + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + tempRoot.setAutoRemove( false ); + + const QString testExecutable = tempRoot.filePath( "example.sh" ); + + cDebug() << "Creating example executable" << testExecutable; + + { + QFile f( testExecutable ); + QVERIFY( f.open( QIODevice::WriteOnly ) ); + f.write( "#! /bin/sh\necho early\nsleep 3\necho late" ); + f.close(); + Calamares::Permissions::apply( testExecutable, 0755 ); + } + + { + Calamares::CommandList l( false ); // no chroot + Calamares::CommandLine c( testExecutable, {}, std::chrono::seconds( 2 ) ); + l.push_back( c ); + + const auto r = l.run(); + QVERIFY( !bool( r ) ); // Because it times out after 2 seconds + // The **command** timed out, but the job result is a generic "error" + // QCOMPARE( r.errorCode(), static_cast>(Calamares::ProcessResult::Code::TimedOut)); + QCOMPARE( r.errorCode(), -1 ); + } +} + +void +LibCalamaresTests::testCommandVerbose() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + tempRoot.setAutoRemove( false ); + + const QString testExecutable = tempRoot.filePath( "example.sh" ); + + cDebug() << "Creating example executable" << testExecutable; + { + QFile f( testExecutable ); + QVERIFY( f.open( QIODevice::WriteOnly ) ); + f.write( "#! /bin/sh\necho one\necho two\necho error 1>&2\nsleep 1; echo three\n" ); + f.close(); + Calamares::Permissions::apply( testExecutable, 0755 ); + } + + // Note that, because of the blocking way run() works, + // in this single-threaded test with no event loop, + // there's nothing for the verbose version to connect + // to for sending output. + + cDebug() << "Running command non-verbose"; + { + Calamares::CommandList l( false ); // no chroot + Calamares::CommandLine c( testExecutable, {}, std::chrono::seconds( 2 ) ); + c.updateVerbose( false ); + QVERIFY( !c.isVerbose() ); + + l.push_back( c ); + + const auto r = l.run(); + QVERIFY( bool( r ) ); + } + + cDebug() << "Running command verbosely"; + { + Calamares::CommandList l( false ); // no chroot + Calamares::CommandLine c( testExecutable, {}, std::chrono::seconds( 2 ) ); + c.updateVerbose( true ); + QVERIFY( c.isVerbose() ); + + l.push_back( c ); + + const auto r = l.run(); + QVERIFY( bool( r ) ); + } +} + +void +LibCalamaresTests::testUmask() +{ + struct stat mystat; + + QTemporaryFile ft; + QVERIFY( ft.open() ); + + // m gets the previous value of the mask (depends on the environment the + // test is run in, might be 002, might be 077), .. + mode_t m = Calamares::setUMask( 022 ); + QCOMPARE( Calamares::setUMask( m ), mode_t( 022 ) ); // But now most recently set was 022 + + for ( mode_t i = 0; i <= 0777 /* octal! */; ++i ) + { + QByteArray name = ( ft.fileName() + QChar( '.' ) + QString::number( i, 8 ) ).toLatin1(); + Calamares::UMask um( i ); + int fd = creat( name, 0777 ); + QVERIFY( fd >= 0 ); + close( fd ); + QFileInfo fi( name ); + QVERIFY( fi.exists() ); + QCOMPARE( stat( name, &mystat ), 0 ); + QCOMPARE( mystat.st_mode & 0777, 0777 & ~i ); + QCOMPARE( unlink( name ), 0 ); + } + QCOMPARE( Calamares::setUMask( 022 ), m ); + QCOMPARE( Calamares::setUMask( m ), mode_t( 022 ) ); +} + +void +LibCalamaresTests::testPermissions() +{ + for ( int i = 0; i <= 0777; ++i ) + { + const QString repr = QString::number( i, 8 ); + QCOMPARE( Calamares::parseFileMode( repr ), i ); + QCOMPARE( Calamares::parseFileMode( QChar( '0' ) + repr ), i ); + QCOMPARE( Calamares::parseFileMode( QStringLiteral( " %1\n" ).arg( repr ) ), i ); + } + + // "rwx" style + QCOMPARE( Calamares::parseFileMode( QStringLiteral( "rwxr-----" ) ), 0740 ); + QCOMPARE( Calamares::parseFileMode( QStringLiteral( "rwxr-x-w-" ) ), 0752 ); + // With leading octal 'o' + QCOMPARE( Calamares::parseFileMode( QStringLiteral( "o644" ) ), 0644 ); + + // Failures + QCOMPARE( Calamares::parseFileMode( QStringLiteral( "1024" ) ), -1 ); + QCOMPARE( Calamares::parseFileMode( QStringLiteral( "O_WRONLY" ) ), -1 ); +} + +void +LibCalamaresTests::testEntropy() +{ + QByteArray data; + + auto r0 = Calamares::getEntropy( 0, data ); + QCOMPARE( Calamares::EntropySource::None, r0 ); + QCOMPARE( data.size(), 0 ); + + auto r1 = Calamares::getEntropy( 16, data ); + QVERIFY( r1 != Calamares::EntropySource::None ); + QCOMPARE( data.size(), 16 ); + // This can randomly fail (but not often) + QVERIFY( data.at( data.size() - 1 ) != char( 0xcb ) ); + + auto r2 = Calamares::getEntropy( 8, data ); + QVERIFY( r2 != Calamares::EntropySource::None ); + QCOMPARE( data.size(), 8 ); + QCOMPARE( r1, r2 ); + // This can randomly fail (but not often) + QVERIFY( data.at( data.size() - 1 ) != char( 0xcb ) ); +} + +void +LibCalamaresTests::testPrintableEntropy() +{ + QString s; + + auto r0 = Calamares::getPrintableEntropy( 0, s ); + QCOMPARE( Calamares::EntropySource::None, r0 ); + QCOMPARE( s.length(), 0 ); + + auto r1 = Calamares::getPrintableEntropy( 16, s ); + QVERIFY( r1 != Calamares::EntropySource::None ); + QCOMPARE( s.length(), 16 ); + for ( QChar c : s ) + { + QVERIFY( c.isPrint() ); + QCOMPARE( c.row(), uchar( 0 ) ); + QVERIFY( c.cell() > 32 ); // ASCII SPACE + QVERIFY( c.cell() < 127 ); + } +} + +void +LibCalamaresTests::testOddSizedPrintable() +{ + QString s; + for ( int l = 0; l <= 37; ++l ) + { + auto r = Calamares::getPrintableEntropy( l, s ); + if ( l == 0 ) + { + QCOMPARE( r, Calamares::EntropySource::None ); + } + else + { + QVERIFY( r != Calamares::EntropySource::None ); + } + QCOMPARE( s.length(), l ); + + for ( QChar c : s ) + { + QVERIFY( c.isPrint() ); + QCOMPARE( c.row(), uchar( 0 ) ); + QVERIFY( c.cell() > 32 ); // ASCII SPACE + QVERIFY( c.cell() < 127 ); + } + } +} + +void +LibCalamaresTests::testPointerSetter() +{ + int special = 17; + + QCOMPARE( special, 17 ); + { + cScopedAssignment p( &special ); + } + QCOMPARE( special, 17 ); + { + cScopedAssignment p( &special ); + p = 18; + } + QCOMPARE( special, 18 ); + { + cScopedAssignment p( &special ); + p = 20; + p = 3; + } + QCOMPARE( special, 3 ); + { + cScopedAssignment< int > p( nullptr ); + } + QCOMPARE( special, 3 ); + { + // "don't do this" .. order of destructors is important + cScopedAssignment p( &special ); + cScopedAssignment q( &special ); + p = 17; + } + QCOMPARE( special, 17 ); + { + // "don't do this" .. order of destructors is important + cScopedAssignment p( &special ); + cScopedAssignment q( &special ); + p = 34; + q = 2; + // q destroyed first, then p + } + QCOMPARE( special, 34 ); +} + +/* Demonstration of Traits support for has-a-method or not. + * + * We have two classes, c1 and c2; one has a method do_the_thing() and the + * other does not. A third class, Thinginator, has a method thingify(), + * which should call do_the_thing() of its argument if it exists. + */ + +struct c1 +{ + int do_the_thing() { return 2; } +}; +struct c2 +{ +}; + +DECLARE_HAS_METHOD( do_the_thing ) + +struct Thinginator +{ +public: + /// When class T has function do_the_thing() + template < class T > + int thingify( T& t, const std::true_type& ) + { + return t.do_the_thing(); + } + + template < class T > + int thingify( T&, const std::false_type& ) + { + return -1; + } + + template < class T > + int thingify( T& t ) + { + return thingify( t, has_do_the_thing< T > {} ); + } +}; + +void +LibCalamaresTests::testTraits() +{ + has_do_the_thing< c1 > x {}; + has_do_the_thing< c2 > y {}; + + QVERIFY( x ); + QVERIFY( !y ); + + c1 c1 {}; + c2 c2 {}; + + QCOMPARE( c1.do_the_thing(), 2 ); + + Thinginator t; + QCOMPARE( t.thingify( c1 ), 2 ); // Calls c1::do_the_thing() + QCOMPARE( t.thingify( c2 ), -1 ); +} + +void +LibCalamaresTests::testVariantStringListCode() +{ + using namespace Calamares; + const QString key( "strings" ); + { + // Things that are not stringlists + QVariantMap m; + QCOMPARE( getStringList( m, key ), QStringList {} ); + m.insert( key, 17 ); + QCOMPARE( getStringList( m, key ), QStringList {} ); + m.insert( key, QVariant {} ); + QCOMPARE( getStringList( m, key ), QStringList {} ); + } + + { + // Things that are **like** stringlists + QVariantMap m; + m.insert( key, QString( "astring" ) ); + QCOMPARE( getStringList( m, key ).count(), 1 ); + QCOMPARE( getStringList( m, key ), + QStringList { "astring" } ); // A single string **can** be considered a stringlist! + m.insert( key, QString( "more strings" ) ); + QCOMPARE( getStringList( m, key ).count(), 1 ); + QCOMPARE( getStringList( m, key ), QStringList { "more strings" } ); + m.insert( key, QString() ); + QCOMPARE( getStringList( m, key ).count(), 1 ); + QCOMPARE( getStringList( m, key ), QStringList { QString() } ); + } + + { + // Things that are definitely stringlists + QVariantMap m; + m.insert( key, QStringList { "aap", "noot" } ); + QCOMPARE( getStringList( m, key ).count(), 2 ); + QVERIFY( getStringList( m, key ).contains( "aap" ) ); + QVERIFY( !getStringList( m, key ).contains( "mies" ) ); + } +} + +void +LibCalamaresTests::testVariantStringListYAMLDashed() +{ + using namespace Calamares; + const QString key( "strings" ); + + // Looks like a stringlist to me + QTemporaryFile f; + QVERIFY( f.open() ); + f.write( R"(--- + strings: + - aap + - noot + - mies + )" ); + f.close(); + bool ok = false; + QVariantMap m = Calamares::YAML::load( f.fileName(), &ok ); + + QVERIFY( ok ); + QCOMPARE( m.count(), 1 ); + QVERIFY( m.contains( key ) ); + + QVERIFY( getStringList( m, key ).contains( "aap" ) ); + QVERIFY( getStringList( m, key ).contains( "mies" ) ); + QVERIFY( !getStringList( m, key ).contains( "lam" ) ); +} + +void +LibCalamaresTests::testVariantStringListYAMLBracketed() +{ + using namespace Calamares; + const QString key( "strings" ); + + // Looks like a stringlist to me + QTemporaryFile f; + QVERIFY( f.open() ); + f.write( R"(--- + strings: [ aap, noot, mies ] + )" ); + f.close(); + bool ok = false; + QVariantMap m = Calamares::YAML::load( f.fileName(), &ok ); + + QVERIFY( ok ); + QCOMPARE( m.count(), 1 ); + QVERIFY( m.contains( key ) ); + + QVERIFY( getStringList( m, key ).contains( "aap" ) ); + QVERIFY( getStringList( m, key ).contains( "mies" ) ); + QVERIFY( !getStringList( m, key ).contains( "lam" ) ); +} + +void +LibCalamaresTests::testStringTruncation() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + using namespace Calamares::String; + // *INDENT-OFF* + const QString longString( R"(--- +--- src/libcalamares/utils/String.h ++++ src/libcalamares/utils/String.h +@@ -62,15 +62,22 @@ DLLEXPORT QString removeDiacritics( const QString& string ); +*/ +DLLEXPORT QString obscure( const QString& string ); + ++/** @brief Parameter for counting lines at beginning and end of string ++ * ++ * This is used by truncateMultiLine() to indicate how many lines from ++ * the beginning and how many from the end should be kept. ++ */ + struct LinesStartEnd + { +- int atStart; +- int atEnd; ++ int atStart = 0; ++ int atEnd = 0; +)" ); + // *INDENT-ON* + + const int sufficientLength = 812; + // There's 18 lines in all + QCOMPARE( longString.count( '\n' ), 18 ); + QVERIFY( longString.length() < sufficientLength ); + + // If we ask for more, we get everything back + QCOMPARE( longString, truncateMultiLine( longString, LinesStartEnd { 20, 0 }, CharCount { sufficientLength } ) ); + QCOMPARE( longString, truncateMultiLine( longString, LinesStartEnd { 0, 20 }, CharCount { sufficientLength } ) ); + + // If we ask for no lines, only characters, we get that + { + auto s = truncateMultiLine( longString, LinesStartEnd { 0, 0 }, CharCount { 4 } ); + QCOMPARE( s.length(), 4 ); + QCOMPARE( s, QString( "---\n" ) ); + } + { + auto s = truncateMultiLine( longString, LinesStartEnd { 0, 0 }, CharCount { sufficientLength } ); + QCOMPARE( s, longString ); + } + + // Lines at the start + { + auto s = truncateMultiLine( longString, LinesStartEnd { 4, 0 }, CharCount { sufficientLength } ); + QVERIFY( s.length() > 1 ); + QVERIFY( longString.startsWith( s ) ); + cDebug() << "Result-line" << Logger::Quote << s; + QCOMPARE( s.count( '\n' ), 4 ); + } + + // Lines at the end + { + auto s = truncateMultiLine( longString, LinesStartEnd { 0, 4 }, CharCount { sufficientLength } ); + QVERIFY( s.length() > 1 ); + QVERIFY( longString.endsWith( s ) ); + cDebug() << "Result-line" << Logger::Quote << s; + QCOMPARE( s.count( '\n' ), 4 ); + } + + // Lines at both ends + { + auto s = truncateMultiLine( longString, LinesStartEnd { 2, 2 }, CharCount { sufficientLength } ); + QVERIFY( s.length() > 1 ); + cDebug() << "Result-line" << Logger::Quote << s; + QCOMPARE( s.count( '\n' ), 4 ); + + auto firsttwo = truncateMultiLine( s, LinesStartEnd { 2, 0 }, CharCount { sufficientLength } ); + auto lasttwo = truncateMultiLine( s, LinesStartEnd { 0, 2 }, CharCount { sufficientLength } ); + QCOMPARE( firsttwo + lasttwo, s ); + QCOMPARE( firsttwo.count( '\n' ), 2 ); + QVERIFY( longString.startsWith( firsttwo ) ); + QVERIFY( longString.endsWith( lasttwo ) ); + } +} + +void +LibCalamaresTests::testStringTruncationShorter() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + using namespace Calamares::String; + + // *INDENT-OFF* + const QString longString( R"(Some strange string artifacts appeared, leading to `{1?}` being +displayed in various user-facing messages. These have been removed +and the translations updated.)" ); + // *INDENT-ON* + + const char NEWLINE = '\n'; + + const int insufficientLength = 42; + // There's 2 newlines in all, no trailing newline + QVERIFY( !longString.endsWith( NEWLINE ) ); + QCOMPARE( longString.count( NEWLINE ), 2 ); + QVERIFY( longString.length() > insufficientLength ); + // Even the first line must be more than the insufficientLength + QVERIFY( longString.indexOf( NEWLINE ) > insufficientLength ); + + // Grab first line, untruncated + { + auto s = truncateMultiLine( longString, LinesStartEnd { 1, 0 } ); + QVERIFY( s.length() > 1 ); + QVERIFY( longString.startsWith( s ) ); + QVERIFY( s.endsWith( NEWLINE ) ); + QVERIFY( s.endsWith( "being\n" ) ); + QVERIFY( s.startsWith( "Some " ) ); + } + + // Grab last line, untruncated + { + auto s = truncateMultiLine( longString, LinesStartEnd { 0, 1 } ); + QVERIFY( s.length() > 1 ); + QVERIFY( longString.endsWith( s ) ); + QVERIFY( !s.endsWith( NEWLINE ) ); + QVERIFY( s.endsWith( "updated." ) ); + QCOMPARE( s.count( NEWLINE ), 0 ); // Because last line doesn't end with a newline + QVERIFY( s.startsWith( "and the " ) ); + } + + // Grab last two lines, untruncated + { + auto s = truncateMultiLine( longString, LinesStartEnd { 0, 2 } ); + QVERIFY( s.length() > 1 ); + QVERIFY( longString.endsWith( s ) ); + QVERIFY( !s.endsWith( NEWLINE ) ); + QVERIFY( s.endsWith( "updated." ) ); + QCOMPARE( s.count( NEWLINE ), 1 ); // Because last line doesn't end with a newline + QVERIFY( s.startsWith( "displayed in " ) ); + } + + // First line, truncated + { + auto s = truncateMultiLine( longString, LinesStartEnd { 1, 0 }, CharCount { insufficientLength } ); + cDebug() << "Result-line" << Logger::Quote << s; + QVERIFY( s.length() > 1 ); + QVERIFY( s.endsWith( NEWLINE ) ); + QVERIFY( s.startsWith( "Some " ) ); + // Because the first line has a newline, the truncated version does too, + // but that makes it one longer than requested. + QCOMPARE( s.length(), insufficientLength + 1 ); + QVERIFY( longString.startsWith( s.left( insufficientLength ) ) ); + } + + // Last line, truncated; this line is quite short + { + const int quiteShort = 8; + QVERIFY( longString.lastIndexOf( NEWLINE ) < longString.length() - quiteShort ); + + auto s = truncateMultiLine( longString, LinesStartEnd { 0, 1 }, CharCount { quiteShort } ); + cDebug() << "Result-line" << Logger::Quote << s; + QVERIFY( s.length() > 1 ); + QVERIFY( !s.endsWith( NEWLINE ) ); // Because the original doesn't either + QVERIFY( s.startsWith( "upda" ) ); + QCOMPARE( s.length(), quiteShort ); // No extra newlines + QVERIFY( longString.endsWith( s ) ); + } + + // First and last, but both truncated + { + const int quiteShort = 16; + QVERIFY( longString.indexOf( NEWLINE ) > quiteShort ); + QVERIFY( longString.lastIndexOf( NEWLINE ) < longString.length() - quiteShort ); + + auto s = truncateMultiLine( longString, LinesStartEnd { 1, 1 }, CharCount { quiteShort } ); + cDebug() << "Result-line" << Logger::Quote << s; + QVERIFY( s.length() > 1 ); + QVERIFY( !s.endsWith( NEWLINE ) ); // Because the original doesn't either + QVERIFY( s.startsWith( "Some " ) ); + QVERIFY( s.endsWith( "updated." ) ); + QCOMPARE( s.length(), quiteShort + 1 ); // Newline between front and back part + } +} + +void +LibCalamaresTests::testStringTruncationDegenerate() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + using namespace Calamares::String; + + // This is quite long, 1 line only, with no newlines + const QString longString( "The portscout new distfile checker has detected that one or more of your " + "ports appears to be out of date. Please take the opportunity to check " + "each of the ports listed below, and if possible and appropriate, " + "submit/commit an update. If any ports have already been updated, you can " + "safely ignore the entry." ); + + const char NEWLINE = '\n'; + const int quiteShort = 16; + QVERIFY( longString.length() > quiteShort ); + QVERIFY( !longString.contains( NEWLINE ) ); + QVERIFY( longString.indexOf( NEWLINE ) < 0 ); + + { + auto s = truncateMultiLine( longString, LinesStartEnd { 1, 0 }, CharCount { quiteShort } ); + cDebug() << "Result-line" << Logger::Quote << s; + QVERIFY( s.length() > 1 ); + QCOMPARE( s.length(), quiteShort ); // No newline between front and back part + QVERIFY( s.startsWith( "The port" ) ); // 8, which is quiteShort / 2 + QVERIFY( s.endsWith( "e entry." ) ); // also 8 chars + + auto t = truncateMultiLine( longString, LinesStartEnd { 2, 2 }, CharCount { quiteShort } ); + QCOMPARE( s, t ); + } +} + +void +LibCalamaresTests::testStringRemoveLeading_data() +{ + QTest::addColumn< QString >( "string" ); + QTest::addColumn< char >( "c" ); + QTest::addColumn< QString >( "result" ); + + QTest::newRow( "empty" ) << QString() << '/' << QString(); + QTest::newRow( "one-slash" ) << QStringLiteral( "/tmp" ) << '/' << QStringLiteral( "tmp" ); + QTest::newRow( "two-slash" ) << QStringLiteral( "//tmp" ) << '/' << QStringLiteral( "tmp" ); + QTest::newRow( "multi-slash" ) << QStringLiteral( "/tmp/p" ) << '/' << QStringLiteral( "tmp/p" ); + QTest::newRow( "later-slash" ) << QStringLiteral( "@/tmp" ) << '/' << QStringLiteral( "@/tmp" ); + QTest::newRow( "all-one-slash" ) << QStringLiteral( "/" ) << '/' << QString(); + QTest::newRow( "all-many-slash" ) << QStringLiteral( "////////////////////" ) << '/' << QString(); + QTest::newRow( "trailing" ) << QStringLiteral( "tmp/" ) << '/' << QStringLiteral( "tmp/" ); +} + +void +LibCalamaresTests::testStringRemoveLeading() +{ + QFETCH( QString, string ); + QFETCH( char, c ); + QFETCH( QString, result ); + + const QString initial = string; + Calamares::String::removeLeading( string, c ); + QCOMPARE( string, result ); +} + +void +LibCalamaresTests::testStringRemoveTrailing_data() +{ + QTest::addColumn< QString >( "string" ); + QTest::addColumn< char >( "c" ); + QTest::addColumn< QString >( "result" ); + + QTest::newRow( "empty" ) << QString() << '/' << QString(); + QTest::newRow( "one-slash" ) << QStringLiteral( "/tmp" ) << '/' << QStringLiteral( "/tmp" ); + QTest::newRow( "two-slash" ) << QStringLiteral( "//tmp" ) << '/' << QStringLiteral( "//tmp" ); + QTest::newRow( "multi-slash" ) << QStringLiteral( "/tmp//p/" ) << '/' << QStringLiteral( "/tmp//p" ); + QTest::newRow( "later-slash" ) << QStringLiteral( "@/tmp/@" ) << '/' << QStringLiteral( "@/tmp/@" ); + QTest::newRow( "later-slash2" ) << QStringLiteral( "@/tmp/@//" ) << '/' << QStringLiteral( "@/tmp/@" ); + QTest::newRow( "all-one-slash" ) << QStringLiteral( "/" ) << '/' << QString(); + QTest::newRow( "all-many-slash" ) << QStringLiteral( "////////////////////" ) << '/' << QString(); + QTest::newRow( "trailing" ) << QStringLiteral( "tmp/" ) << '/' << QStringLiteral( "tmp" ); +} + +void +LibCalamaresTests::testStringRemoveTrailing() +{ + QFETCH( QString, string ); + QFETCH( char, c ); + QFETCH( QString, result ); + + const QString initial = string; + Calamares::String::removeTrailing( string, c ); + QCOMPARE( string, result ); +} + +void +LibCalamaresTests::testStringMacroExpander_data() +{ + QTest::addColumn< QString >( "source" ); + QTest::addColumn< QString >( "result" ); + QTest::addColumn< QStringList >( "errors" ); + + QTest::newRow( "empty " ) << QString() << QString() << QStringList {}; + QTest::newRow( "constant" ) << QStringLiteral( "bunnies!" ) << QStringLiteral( "bunnies!" ) << QStringList {}; + QTest::newRow( "escaped " ) << QStringLiteral( "$$bun" ) << QStringLiteral( "$bun" ) + << QStringList {}; // Double $$ is an escaped $ + QTest::newRow( "whole " ) << QStringLiteral( "${ROOT}" ) << QStringLiteral( "wortel" ) << QStringList {}; + QTest::newRow( "unbraced" ) << QStringLiteral( "$ROOT" ) << QStringLiteral( "wortel" ) + << QStringList {}; // Does not need {} + QTest::newRow( "bad-var1" ) << QStringLiteral( "${ROOF}" ) << QStringLiteral( "${ROOF}" ) + << QStringList { QStringLiteral( "ROOF" ) }; // Not replaced + QTest::newRow( "twice " ) << QStringLiteral( "${ROOT}x${ROOT}" ) << QStringLiteral( "wortelxwortel" ) + << QStringList {}; + QTest::newRow( "bad-var2" ) << QStringLiteral( "${ROOT}x${ROPE}" ) << QStringLiteral( "wortelx${ROPE}" ) + << QStringList { QStringLiteral( "ROPE" ) }; // Not replaced + // This is a borked string with a "nested" variable. The variable-name- + // scanner goes from ${ to the next } and tries to match that. + QTest::newRow( "confuse1" ) << QStringLiteral( "${RO${ROOT}" ) << QStringLiteral( "${ROwortel" ) + << QStringList { "RO${ROOT" }; + // This one doesn't have a { for the first name to match with + QTest::newRow( "confuse2" ) << QStringLiteral( "$RO${ROOT}" ) << QStringLiteral( "$ROwortel" ) + << QStringList { "RO" }; + // Here we see it just doesn't nest + QTest::newRow( "confuse3" ) << QStringLiteral( "${RO${ROOT}}" ) << QStringLiteral( "${ROwortel}" ) + << QStringList { "RO${ROOT" }; +} + +void +LibCalamaresTests::testStringMacroExpander() +{ + QHash< QString, QString > dict; + dict.insert( QStringLiteral( "ROOT" ), QStringLiteral( "wortel" ) ); + + Calamares::String::DictionaryExpander d; + d.insert( QStringLiteral( "ROOT" ), QStringLiteral( "wortel" ) ); + + QFETCH( QString, source ); + QFETCH( QString, result ); + QFETCH( QStringList, errors ); + + QString km_expanded = KMacroExpander::expandMacros( source, dict, '$' ); + QCOMPARE( km_expanded, result ); + + QString de_expanded = d.expand( source ); + QCOMPARE( de_expanded, result ); + QCOMPARE( d.errorNames(), errors ); + QCOMPARE( d.hasErrors(), !errors.isEmpty() ); +} + +static QString +dirname( const QTemporaryDir& d ) +{ + return d.path().split( '/' ).last(); +} +static QString +dirname( const QDir& d ) +{ + return d.absolutePath().split( '/' ).last(); +} + +// Method under test +extern bool relativeChangeDirectory( QDir& directory, const QString& subdir ); + +void +LibCalamaresTests::testRunnerDirs() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + QDir startDir( QDir::current() ); + QTemporaryDir tempDir( "./utilstest" ); + QVERIFY( tempDir.isValid() ); + QVERIFY( startDir.isReadable() ); + + // Test changing "downward" + { + QDir testDir( QDir::current() ); + QCOMPARE( startDir, testDir ); + } + + { + QDir testDir( QDir::current() ); + const bool could_change_to_dot = relativeChangeDirectory( testDir, QStringLiteral( "." ) ); + QVERIFY( could_change_to_dot ); + QCOMPARE( startDir, testDir ); + } + + { + // The tempDir was created inside the current directory, we want only the subdir-name + QDir testDir( QDir::current() ); + const bool could_change_to_temp = relativeChangeDirectory( testDir, dirname( tempDir ) ); + QVERIFY( could_change_to_temp ); + QVERIFY( startDir != testDir ); + QVERIFY( testDir.absolutePath().startsWith( startDir.absolutePath() ) ); + } + + // Test changing to something that doesn't exist + { + QDir testDir( QDir::current() ); + const bool could_change_to_bogus = relativeChangeDirectory( testDir, QStringLiteral( "bogus" ) ); + QVERIFY( !could_change_to_bogus ); + QCOMPARE( startDir, testDir ); // Must be unchanged + } + + // Testing escape-from-start + { + // Escape briefly from the start + QDir testDir( QDir::current() ); + const bool could_change_to_current + = relativeChangeDirectory( testDir, QStringLiteral( "../" ) + dirname( startDir ) ); + QVERIFY( could_change_to_current ); + QCOMPARE( startDir, testDir ); // The change succeeded, but net effect is zero + + const bool could_change_to_temp = relativeChangeDirectory( + testDir, QStringLiteral( "../" ) + dirname( startDir ) + QStringLiteral( "/./" ) + dirname( tempDir ) ); + QVERIFY( could_change_to_temp ); + QVERIFY( startDir != testDir ); + QVERIFY( testDir.absolutePath().startsWith( startDir.absolutePath() ) ); + } + + { + // Escape? + QDir testDir( QDir::current() ); + const bool could_change_to_parent = relativeChangeDirectory( testDir, QStringLiteral( "../" ) ); + QVERIFY( !could_change_to_parent ); + QCOMPARE( startDir, testDir ); // Change failed + + const bool could_change_to_tmp = relativeChangeDirectory( testDir, QStringLiteral( "/tmp" ) ); + QVERIFY( !could_change_to_tmp ); + QCOMPARE( startDir, testDir ); + + const bool could_change_to_elsewhere = relativeChangeDirectory( testDir, QStringLiteral( "../src" ) ); + QVERIFY( !could_change_to_elsewhere ); + QCOMPARE( startDir, testDir ); + } +} + +// Method under test +extern std::pair< bool, QDir > calculateWorkingDirectory( Calamares::Utils::RunLocation location, + const QString& directory ); + +void +LibCalamaresTests::testCalculateWorkingDirectory() +{ + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + + if ( !gs ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + gs = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + } + QVERIFY( gs ); + + // Working with a rootMountPoint set + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + gs->insert( "rootMountPoint", tempRoot.path() ); + + { + auto [ ok, d ] = calculateWorkingDirectory( Calamares::System::RunLocation::RunInHost, QString() ); + QVERIFY( ok ); + QCOMPARE( d, QDir::current() ); + } + { + auto [ ok, d ] = calculateWorkingDirectory( Calamares::System::RunLocation::RunInTarget, QString() ); + QVERIFY( ok ); + QCOMPARE( d.absolutePath(), tempRoot.path() ); + } + + gs->remove( "rootMountPoint" ); + { + auto [ ok, d ] = calculateWorkingDirectory( Calamares::System::RunLocation::RunInHost, QString() ); + QVERIFY( ok ); + QCOMPARE( d, QDir::current() ); + } + { + auto [ ok, d ] = calculateWorkingDirectory( Calamares::System::RunLocation::RunInTarget, QString() ); + QVERIFY( !ok ); + QCOMPARE( d, QDir::current() ); + } +} + +void +LibCalamaresTests::testRunnerOutput() +{ + cDebug() << "Testing ls"; + { + Calamares::Utils::Runner r( { "ls", "-d", "." } ); + QSignalSpy spy( &r, &decltype( r )::output ); + r.enableOutputProcessing(); + + auto result = r.run(); + QCOMPARE( result.getExitCode(), 0 ); + QCOMPARE( result.getOutput(), QString() ); + QCOMPARE( spy.count(), 1 ); + } + + cDebug() << "Testing cat"; + { + Calamares::Utils::Runner r( { "cat" } ); + QSignalSpy spy( &r, &decltype( r )::output ); + r.enableOutputProcessing().setInput( QStringLiteral( "hello\nworld\n\n!\n" ) ); + + { + auto result = r.run(); + QCOMPARE( result.getExitCode(), 0 ); + QCOMPARE( result.getOutput(), QString() ); + QCOMPARE( spy.count(), 4 ); + } + + r.setInput( QStringLiteral( "yo\ndogg" ) ); + { + auto result = r.run(); + QCOMPARE( result.getExitCode(), 0 ); + QCOMPARE( result.getOutput(), QString() ); + QCOMPARE( spy.count(), 6 ); // 4 from before, +2 here + } + } + + cDebug() << "Testing cat (again)"; + { + QStringList collectedOutput; + + Calamares::Utils::Runner r( { "cat" } ); + r.enableOutputProcessing().setInput( QStringLiteral( "hello\nworld\n\n!\n" ) ); + QObject::connect( &r, &decltype( r )::output, [ &collectedOutput ]( QString s ) { collectedOutput << s; } ); + + { + auto result = r.run(); + QCOMPARE( result.getExitCode(), 0 ); + QCOMPARE( result.getOutput(), QString() ); + QCOMPARE( collectedOutput.count(), 4 ); + QVERIFY( collectedOutput.contains( QStringLiteral( "world\n" ) ) ); + } + + r.setInput( QStringLiteral( "yo\ndogg" ) ); + { + auto result = r.run(); + QCOMPARE( result.getExitCode(), 0 ); + QCOMPARE( result.getOutput(), QString() ); + QCOMPARE( collectedOutput.count(), 6 ); + QVERIFY( collectedOutput.contains( QStringLiteral( "dogg" ) ) ); // no newline + } + } +} + +Calamares::System* +file_setup( const QTemporaryDir& tempRoot ) +{ + Calamares::System* ss = Calamares::System::instance(); + if ( !ss ) + { + ss = new Calamares::System( true ); + } + + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + if ( !gs ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + gs = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + } + if ( gs ) + { + // Working with a rootMountPoint set + gs->insert( "rootMountPoint", tempRoot.path() ); + } + return ss; +} + +void +LibCalamaresTests::testReadWriteFile() +{ + static const QByteArray otherContents( "first\nsecond\n" ); + + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + auto* ss = file_setup( tempRoot ); + + QVERIFY( ss ); + { + auto fullPath = ss->createTargetFile( "test0", QByteArray(), Calamares::System::WriteMode::Overwrite ); + QVERIFY( fullPath ); + QVERIFY( !fullPath.path().isEmpty() ); + + QFileInfo fi( fullPath.path() ); + QVERIFY( fi.exists() ); + QVERIFY( fi.isFile() ); + QCOMPARE( fi.size(), 0 ); + } + // It won't overwrite unless you ask for it + { + auto fullPath = ss->createTargetFile( "test0", otherContents ); + QVERIFY( !fullPath ); // Failed, because it won't overwrite + QCOMPARE( fullPath.code(), decltype( fullPath )::Code::AlreadyExists ); + QVERIFY( fullPath.path().isEmpty() ); // Because it wasn't written + + QFileInfo fi( tempRoot.filePath( "test0" ) ); // Compute the name some other way + QVERIFY( fi.exists() ); + QVERIFY( fi.isFile() ); + QCOMPARE( fi.size(), 0 ); + } + // But it will if you say so explicitly + { + auto fullPath = ss->createTargetFile( "test0", otherContents, Calamares::System::WriteMode::Overwrite ); + QVERIFY( fullPath ); + QVERIFY( !fullPath.path().isEmpty() ); + + QFileInfo fi( fullPath.path() ); + QVERIFY( fi.exists() ); + QVERIFY( fi.isFile() ); + QCOMPARE( fi.size(), 13 ); + } + + // Now it's been written, we can read it, too + { + auto contents = ss->readTargetFile( "test0" ); + QVERIFY( !contents.isEmpty() ); + QCOMPARE( contents.count(), 2 ); + QCOMPARE( contents[ 0 ], QStringLiteral( "first" ) ); // No trailing \n + QCOMPARE( contents[ 1 ], QStringLiteral( "second" ) ); // No trailing \n + } +} + +QTEST_GUILESS_MAIN( LibCalamaresTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/libcalamares/utils/Traits.h b/calamares/src/libcalamares/utils/Traits.h new file mode 100644 index 0000000..9c0282b --- /dev/null +++ b/calamares/src/libcalamares/utils/Traits.h @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_TRAITS_H +#define UTILS_TRAITS_H + +#include + +namespace Calamares +{ + +/** @brief Traits machinery lives in this namespace + * + * The primary purpose of this namespace is to hold machinery that + * is created by the DECLARE_HAS_METHOD macro. + * + * The DECLARE_HAS_METHOD macro builds machinery to check whether + * a class has a particular named method. This can be used to + * specialize templates elsewhere for use with classes with, or without, + * the named method. + * + * To use the machinery (which is not that sophisticated): + * + * - Put `DECLARE_HAS_METHOD(myFunction)` somewhere in file scope. + * This puts together the machinery for detecting if `myFunction` + * is a method of some class. + * - At global scope, `has_myFunction` is now either std::true_type, + * if the type `T` has a method `T::myFunction`, or std::false_type, + * if it does not. + * + * To specialize template methods based on the presence of the named + * method, write **three** overloads: + * + * - `template myMethod(args ..., const std::true_type& )` + * This is the implementation where class T has `myFunction`. + * - `template myMethod(args ..., const std::false_type& )` + * This is the implementation without. + * - `template myMethod(args ...)` is the general implementation, + * which can call the specialized implementations with + * `return myMethod(args ..., has_myFunction{})` + */ +namespace Traits +{ +template < class > +struct sfinae_true : std::true_type +{ +}; +} // namespace Traits +} // namespace Calamares + +#define DECLARE_HAS_METHOD( m ) \ + namespace Calamares \ + { \ + namespace Traits \ + { \ + struct has_##m \ + { \ + template < class T > \ + static auto f( int ) -> sfinae_true< decltype( &T::m ) >; \ + template < class T > \ + static auto f( long ) -> std::false_type; \ + template < class T > \ + using t = decltype( f< T >( 0 ) ); \ + }; \ + } \ + } \ + template < class T > \ + using has_##m = Calamares::Traits::has_##m ::t< T >; + +#endif diff --git a/calamares/src/libcalamares/utils/UMask.cpp b/calamares/src/libcalamares/utils/UMask.cpp new file mode 100644 index 0000000..19e1493 --- /dev/null +++ b/calamares/src/libcalamares/utils/UMask.cpp @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "UMask.h" + +#include +#include + +namespace Calamares +{ +mode_t +setUMask( mode_t u ) +{ + return umask( u ); +} + +UMask::UMask( mode_t u ) + : m_mode( setUMask( u ) ) +{ +} + +UMask::~UMask() +{ + setUMask( m_mode ); +} + +static_assert( UMask::Safe == ( S_IRGRP | S_IWGRP | S_IXGRP | S_IROTH | S_IWOTH | S_IXOTH ), "Bad permissions." ); + +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/UMask.h b/calamares/src/libcalamares/utils/UMask.h new file mode 100644 index 0000000..552445c --- /dev/null +++ b/calamares/src/libcalamares/utils/UMask.h @@ -0,0 +1,50 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ +#ifndef UTILS_UMASK_H +#define UTILS_UMASK_H + +#include "DllMacro.h" + +#include + +namespace Calamares +{ +/** @brief Wrapper for umask(2) + * + * Like umask(2), sets the umask and returns the previous value of the mask. + */ +DLLEXPORT mode_t setUMask( mode_t u ); + +/** @brief RAII for setting and re-setting umask. + * + * Create an object of this class to set the umask, + * and the umask is reset to its original value when + * the object goes out of scope. + */ +class DLLEXPORT UMask +{ +public: + UMask( mode_t u ); + ~UMask(); + + /** @brief a "safe" umask + * + * This umask will switch off group- and other- permissions for + * files, so that the file cannot be read, written, or executed + * except by the owner. + */ + static constexpr mode_t Safe = 077; // octal! +private: + mode_t m_mode; +}; +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/Units.h b/calamares/src/libcalamares/utils/Units.h new file mode 100644 index 0000000..824679b --- /dev/null +++ b/calamares/src/libcalamares/utils/Units.h @@ -0,0 +1,181 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_UNITS_H +#define UTILS_UNITS_H + +#include + +namespace Calamares +{ +/// @brief Type for expressing units +using intunit_t = quint64; + +namespace Units +{ + +/** User defined literals, 1_KB is 1 KiloByte (= 10^3 bytes) */ +constexpr qint64 +operator""_KB( unsigned long long m ) +{ + return qint64( m ) * 1000; +} + +/** User defined literals, 1_KiB is 1 KibiByte (= 2^10 bytes) */ +constexpr qint64 +operator""_KiB( unsigned long long m ) +{ + return qint64( m ) * 1024; +} + +/** User defined literals, 1_MB is 1 MegaByte (= 10^6 bytes) */ +constexpr qint64 +operator""_MB( unsigned long long m ) +{ + return operator""_KB( m ) * 1000; +} + +/** User defined literals, 1_MiB is 1 MibiByte (= 2^20 bytes) */ +constexpr qint64 +operator""_MiB( unsigned long long m ) +{ + return operator""_KiB( m ) * 1024; +} + +/** User defined literals, 1_GB is 1 GigaByte (= 10^9 bytes) */ +constexpr qint64 +operator""_GB( unsigned long long m ) +{ + return operator""_MB( m ) * 1000; +} + +/** User defined literals, 1_GiB is 1 GibiByte (= 2^30 bytes) */ +constexpr qint64 +operator""_GiB( unsigned long long m ) +{ + return operator""_MiB( m ) * 1024; +} + +} // namespace Units + +constexpr qint64 +KBtoBytes( unsigned long long m ) +{ + return Units::operator""_KB( m ); +} + +constexpr qint64 +KiBtoBytes( unsigned long long m ) +{ + return Units::operator""_KiB( m ); +} + +constexpr qint64 +MBtoBytes( unsigned long long m ) +{ + return Units::operator""_MB( m ); +} + +constexpr qint64 +MiBtoBytes( unsigned long long m ) +{ + return Units::operator""_MiB( m ); +} + +constexpr qint64 +GBtoBytes( unsigned long long m ) +{ + return Units::operator""_GB( m ); +} + +constexpr qint64 +GiBtoBytes( unsigned long long m ) +{ + return Units::operator""_GiB( m ); +} + +constexpr qint64 +KBtoBytes( double m ) +{ + return qint64( m * 1000 ); +} + +constexpr qint64 +KiBtoBytes( double m ) +{ + return qint64( m * 1024 ); +} + +constexpr qint64 +MBtoBytes( double m ) +{ + return qint64( m * 1000 * 1000 ); +} + +constexpr qint64 +MiBtoBytes( double m ) +{ + return qint64( m * 1024 * 1024 ); +} + +constexpr qint64 +GBtoBytes( double m ) +{ + return qint64( m * 1000 * 1000 * 1000 ); +} + +constexpr qint64 +GiBtoBytes( double m ) +{ + return qint64( m * 1024 * 1024 * 1024 ); +} + +constexpr int +BytesToMiB( qint64 b ) +{ + return int( b / 1024 / 1024 ); +} + +// TODO: deprecate signed version +constexpr int +BytesToGiB( qint64 b ) +{ + return int( b / 1024 / 1024 / 1024 ); +} + +constexpr intunit_t +BytesToGiB( intunit_t b ) +{ + return b / 1024 / 1024 / 1024; +} + +constexpr qint64 +alignBytesToBlockSize( qint64 bytes, qint64 blocksize ) +{ + qint64 blocks = bytes / blocksize; + + if ( blocks * blocksize != bytes ) + { + ++blocks; + } + return blocks * blocksize; +} + +constexpr qint64 +bytesToSectors( qint64 bytes, qint64 blocksize ) +{ + return alignBytesToBlockSize( alignBytesToBlockSize( bytes, blocksize ), MiBtoBytes( 1ULL ) ) / blocksize; +} + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/Variant.cpp b/calamares/src/libcalamares/utils/Variant.cpp new file mode 100644 index 0000000..98cb7ef --- /dev/null +++ b/calamares/src/libcalamares/utils/Variant.cpp @@ -0,0 +1,139 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2013-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from Tomahawk, portions: + * SPDX-FileCopyrightText: 2010-2011 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2010-2011 Leo Franchi + * SPDX-FileCopyrightText: 2010-2012 Jeff Mitchell + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Variant.h" + +#include "Logger.h" +#include "compat/Variant.h" + +#include +#include + +namespace Calamares +{ +bool +getBool( const QVariantMap& map, const QString& key, bool d ) +{ + if ( map.contains( key ) ) + { + auto v = map.value( key ); + if ( Calamares::typeOf( v ) == Calamares::BoolVariantType ) + { + return v.toBool(); + } + } + return d; +} + +QString +getString( const QVariantMap& map, const QString& key, const QString& d ) +{ + if ( map.contains( key ) ) + { + auto v = map.value( key ); + if ( Calamares::typeOf( v ) == Calamares::StringVariantType ) + { + return v.toString(); + } + } + return d; +} + +QStringList +getStringList( const QVariantMap& map, const QString& key, const QStringList& d ) +{ + if ( map.contains( key ) ) + { + auto v = map.value( key ); + if ( v.canConvert< QStringList >() ) + { + return v.toStringList(); + } + } + return d; +} + +QList< QVariant > +getList( const QVariantMap& map, const QString& key, const QList< QVariant >& d ) +{ + if ( map.contains( key ) ) + { + auto v = map.value( key ); + if ( v.canConvert< QVariantList >() ) + { + return v.toList(); + } + } + return d; +} + +qint64 +getInteger( const QVariantMap& map, const QString& key, qint64 d ) +{ + if ( map.contains( key ) ) + { + auto v = map.value( key ); + return v.toString().toLongLong( nullptr, 0 ); + } + return d; +} + +quint64 +getUnsignedInteger( const QVariantMap& map, const QString& key, quint64 d ) +{ + if ( map.contains( key ) ) + { + auto v = map.value( key ); + return v.toString().toULongLong( nullptr, 0 ); + } + return d; +} + +double +getDouble( const QVariantMap& map, const QString& key, double d ) +{ + if ( map.contains( key ) ) + { + auto v = map.value( key ); + if ( Calamares::typeOf( v ) == Calamares::IntVariantType ) + { + return v.toInt(); + } + else if ( Calamares::typeOf( v ) == Calamares::DoubleVariantType ) + { + return v.toDouble(); + } + } + return d; +} + +QVariantMap +getSubMap( const QVariantMap& map, const QString& key, bool& success, const QVariantMap& d ) +{ + success = false; + if ( map.contains( key ) ) + { + auto v = map.value( key ); + if ( Calamares::typeOf( v ) == Calamares::MapVariantType ) + { + success = true; + return v.toMap(); + } + } + return d; +} + +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/Variant.h b/calamares/src/libcalamares/utils/Variant.h new file mode 100644 index 0000000..1c1784f --- /dev/null +++ b/calamares/src/libcalamares/utils/Variant.h @@ -0,0 +1,78 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2013-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#ifndef UTILS_VARIANT_H +#define UTILS_VARIANT_H + +#include "DllMacro.h" + +#include +#include +#include + +namespace Calamares +{ +/** + * Get a bool value from a mapping with a given key; returns @p d if no value. + */ +DLLEXPORT bool getBool( const QVariantMap& map, const QString& key, bool d = false ); + +/** @brief Get a string value from a mapping with a given key; returns @p d if no value. + * + * The value must be an actual string; numbers are not automatically converted to strings, + * nor are lists flattened or converted. + */ +DLLEXPORT QString getString( const QVariantMap& map, const QString& key, const QString& d = QString() ); + +/** @brief Get a string list from a mapping with a given key; returns @p d if no value. + * + * This is slightly more lenient than getString(), and a single-string value will + * be returned as a 1-item list. + */ +DLLEXPORT QStringList getStringList( const QVariantMap& map, const QString& key, const QStringList& d = QStringList() ); + +/** + * Get a list from a mapping with a given key; returns @p d if no value. + */ +DLLEXPORT QList< QVariant > +getList( const QVariantMap& map, const QString& key, const QList< QVariant >& d = QList< QVariant >() ); + +/** + * Get an integer value from a mapping with a given key; returns @p d if no value. + */ +DLLEXPORT qint64 getInteger( const QVariantMap& map, const QString& key, qint64 d = 0 ); + +/** + * Get an unsigned integer value from a mapping with a given key; returns @p d if no value. + */ +DLLEXPORT quint64 getUnsignedInteger( const QVariantMap& map, const QString& key, quint64 d = 0 ); + +/** + * Get a double value from a mapping with a given key (integers are converted); returns @p d if no value. + */ +DLLEXPORT double getDouble( const QVariantMap& map, const QString& key, double d = 0.0 ); + +/** + * Returns a sub-map (i.e. a nested map) from a given mapping with a + * given key. @p success is set to true if the @p key exists + * in @p map and converts to a map, false otherwise. + * + * Returns @p d if there is no such key or it is not a map-value. + * (e.g. if @p success is false). + */ +DLLEXPORT QVariantMap getSubMap( const QVariantMap& map, + const QString& key, + bool& success, + const QVariantMap& d = QVariantMap() ); +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/Yaml.cpp b/calamares/src/libcalamares/utils/Yaml.cpp new file mode 100644 index 0000000..cc3fd07 --- /dev/null +++ b/calamares/src/libcalamares/utils/Yaml.cpp @@ -0,0 +1,330 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + * + */ +#include "Yaml.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" + +#include +#include +#include +#include + +void +operator>>( const ::YAML::Node& node, QStringList& v ) +{ + for ( size_t i = 0; i < node.size(); ++i ) + { + v.append( QString::fromStdString( node[ i ].as< std::string >() ) ); + } +} + +namespace Calamares +{ +namespace YAML +{ +QVariant +toVariant( const ::YAML::Node& node ) +{ + switch ( node.Type() ) + { + case ::YAML::NodeType::Scalar: + return scalarToVariant( node ); + + case ::YAML::NodeType::Sequence: + return sequenceToVariant( node ); + + case ::YAML::NodeType::Map: + return mapToVariant( node ); + + case ::YAML::NodeType::Null: + case ::YAML::NodeType::Undefined: + return QVariant(); + } + __builtin_unreachable(); +} + +QVariant +scalarToVariant( const ::YAML::Node& scalarNode ) +{ + static const auto yamlScalarTrueValues = QRegularExpression( "^(true|True|TRUE|on|On|ON)$" ); + static const auto yamlScalarFalseValues = QRegularExpression( "^(false|False|FALSE|off|Off|OFF)$" ); + static const auto yamlIntegerValues = QRegularExpression( "^[-+]?\\d+$" ); + static const auto yamlFloatValues = QRegularExpression( "^[-+]?\\d*\\.?\\d+$" ); + + std::string stdScalar = scalarNode.as< std::string >(); + QString scalarString = QString::fromStdString( stdScalar ); + if ( yamlScalarTrueValues.match( scalarString ).hasMatch() ) + { + return QVariant( true ); + } + if ( yamlScalarFalseValues.match( scalarString ).hasMatch() ) + { + return QVariant( false ); + } + if ( yamlIntegerValues.match( scalarString ).hasMatch() ) + { + return QVariant( scalarString.toLongLong() ); + } + if ( yamlFloatValues.match( scalarString ).hasMatch() ) + { + return QVariant( scalarString.toDouble() ); + } + return QVariant( scalarString ); +} + +QVariantList +sequenceToVariant( const ::YAML::Node& sequenceNode ) +{ + QVariantList vl; + for ( ::YAML::const_iterator it = sequenceNode.begin(); it != sequenceNode.end(); ++it ) + { + vl << toVariant( *it ); + } + return vl; +} + +QVariantMap +mapToVariant( const ::YAML::Node& mapNode ) +{ + QVariantMap vm; + for ( ::YAML::const_iterator it = mapNode.begin(); it != mapNode.end(); ++it ) + { + vm.insert( QString::fromStdString( it->first.as< std::string >() ), toVariant( it->second ) ); + } + return vm; +} + +QStringList +toStringList( const ::YAML::Node& listNode ) +{ + QStringList l; + listNode >> l; + return l; +} + +void +explainException( const ::YAML::Exception& e, const QByteArray& yamlData, const char* label ) +{ + cWarning() << "YAML error " << e.what() << "in" << label << '.'; + explainException( e, yamlData ); +} + +void +explainException( const ::YAML::Exception& e, const QByteArray& yamlData, const QString& label ) +{ + cWarning() << "YAML error " << e.what() << "in" << label << '.'; + explainException( e, yamlData ); +} + +void +explainException( const ::YAML::Exception& e, const QByteArray& yamlData ) +{ + if ( ( e.mark.line >= 0 ) && ( e.mark.column >= 0 ) ) + { + // Try to show the line where it happened. + int linestart = 0; + for ( int linecount = 0; linecount < e.mark.line; ++linecount ) + { + linestart = yamlData.indexOf( '\n', linestart ); + // No more \ns found, weird + if ( linestart < 0 ) + { + break; + } + linestart += 1; // Skip that \n + } + int lineend = linestart; + if ( linestart >= 0 ) + { + lineend = yamlData.indexOf( '\n', linestart ); + if ( lineend < 0 ) + { + lineend = yamlData.length(); + } + } + + int rangestart = linestart; + int rangeend = lineend; + // Adjust range (linestart..lineend) so it's not too long + if ( ( linestart >= 0 ) && ( e.mark.column > 30 ) ) + { + rangestart += ( e.mark.column - 30 ); + } + if ( ( linestart >= 0 ) && ( rangeend - rangestart > 40 ) ) + { + rangeend = rangestart + 40; + } + + if ( linestart >= 0 ) + { + cWarning() << "offending YAML data:" << yamlData.mid( rangestart, rangeend - rangestart ).constData(); + } + } +} + +QVariantMap +load( const QFileInfo& fi, bool* ok ) +{ + return load( fi.absoluteFilePath(), ok ); +} + +QVariantMap +load( const QString& filename, bool* ok ) +{ + if ( ok ) + { + *ok = false; + } + + QFile yamlFile( filename ); + QVariant yamlContents; + if ( yamlFile.exists() && yamlFile.open( QFile::ReadOnly | QFile::Text ) ) + { + QByteArray ba = yamlFile.readAll(); + try + { + ::YAML::Node doc = ::YAML::Load( ba.constData() ); + yamlContents = toVariant( doc ); + } + catch ( ::YAML::Exception& e ) + { + explainException( e, ba, filename ); + return QVariantMap(); + } + } + + if ( yamlContents.isValid() && !yamlContents.isNull() + && Calamares::typeOf( yamlContents ) == Calamares::MapVariantType ) + { + if ( ok ) + { + *ok = true; + } + return yamlContents.toMap(); + } + + return QVariantMap(); +} + +/// @brief Convenience function writes @p indent times four spaces +static void +writeIndent( QFile& f, int indent ) +{ + while ( indent-- > 0 ) + { + f.write( " " ); + } +} + +// forward declaration +static bool dumpYaml( QFile& f, const QVariantMap& map, int indent ); + +// It's a quote +static const char quote[] = "\""; +static const char newline[] = "\n"; + +/// @brief Recursive helper to dump a single value +static void +dumpYamlElement( QFile& f, const QVariant& value, int indent ) +{ + const auto t = Calamares::typeOf( value ); + if ( t == Calamares::BoolVariantType ) + { + f.write( value.toBool() ? "true" : "false" ); + } + else if ( t == Calamares::StringVariantType ) + { + f.write( quote ); + f.write( value.toString().toUtf8() ); + f.write( quote ); + } + else if ( t == Calamares::IntVariantType ) + { + f.write( QString::number( value.toInt() ).toUtf8() ); + } + else if ( t == Calamares::LongLongVariantType ) + { + f.write( QString::number( value.toLongLong() ).toUtf8() ); + } + else if ( t == Calamares::DoubleVariantType ) + { + f.write( QString::number( value.toDouble(), 'f', 2 ).toUtf8() ); + } + else if ( value.canConvert< qulonglong >() ) + { + // This one needs to be *after* bool, int, double to avoid this branch + // .. grabbing those convertible types un-necessarily. + f.write( QString::number( value.toULongLong() ).toUtf8() ); + } + else if ( t == Calamares::ListVariantType ) + { + int c = 0; + for ( const auto& it : value.toList() ) + { + ++c; + f.write( newline ); + writeIndent( f, indent + 1 ); + f.write( "- " ); + dumpYamlElement( f, it, indent + 1 ); + } + if ( !c ) // i.e. list was empty + { + f.write( "[]" ); + } + } + else if ( t == Calamares::MapVariantType ) + { + f.write( newline ); + dumpYaml( f, value.toMap(), indent + 1 ); + } + else + { + f.write( "<" ); + f.write( value.typeName() ); + f.write( ">" ); + } +} + +/// @brief Recursive helper to dump @p map to file +static bool +dumpYaml( QFile& f, const QVariantMap& map, int indent ) +{ + for ( auto it = map.cbegin(); it != map.cend(); ++it ) + { + writeIndent( f, indent ); + f.write( quote ); + f.write( it.key().toUtf8() ); + f.write( quote ); + f.write( ": " ); + dumpYamlElement( f, it.value(), indent ); + f.write( newline ); + } + return true; +} + +bool +save( const QString& filename, const QVariantMap& map ) +{ + QFile f( filename ); + if ( !f.open( QFile::WriteOnly ) ) + { + return false; + } + + f.write( "# YAML dump\n---\n" ); + return dumpYaml( f, map, 0 ); +} + +} // namespace YAML +} // namespace Calamares diff --git a/calamares/src/libcalamares/utils/Yaml.h b/calamares/src/libcalamares/utils/Yaml.h new file mode 100644 index 0000000..b39db68 --- /dev/null +++ b/calamares/src/libcalamares/utils/Yaml.h @@ -0,0 +1,85 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* + * YAML conversions and YAML convenience header. + * + * Includes the system YAMLCPP headers without warnings (by switching off + * the expected warnings) and provides a handful of methods for + * converting between YAML and QVariant. + */ +#ifndef UTILS_YAML_H +#define UTILS_YAML_H + +#include "DllMacro.h" + +#include +#include +#include +#include + +class QByteArray; +class QFileInfo; + +// The yaml-cpp headers are not C++11 warning-proof, especially +// with picky compilers like Clang 8. Since we use Clang for the +// find-all-the-warnings case, switch those warnings off for +// the we-can't-change-them system headers. +QT_WARNING_PUSH +QT_WARNING_DISABLE_CLANG( "-Wzero-as-null-pointer-constant" ) +QT_WARNING_DISABLE_CLANG( "-Wshadow" ) +QT_WARNING_DISABLE_CLANG( "-Wfloat-equal" ) +QT_WARNING_DISABLE_CLANG( "-Wsuggest-destructor-override" ) + +#include + +QT_WARNING_POP + +/// @brief Appends all the elements of @p node to the string list @p v +DLLEXPORT void operator>>( const ::YAML::Node& node, QStringList& v ); + +namespace Calamares +{ +namespace YAML +{ +/** + * Loads a given @p filename and returns the YAML data + * as a QVariantMap. If filename doesn't exist, or is + * malformed in some way, returns an empty map and sets + * @p *ok to false. Otherwise sets @p *ok to true. + */ +DLLEXPORT QVariantMap load( const QString& filename, bool* ok = nullptr ); +/** Convenience overload. */ +DLLEXPORT QVariantMap load( const QFileInfo&, bool* ok = nullptr ); + +DLLEXPORT QVariant toVariant( const ::YAML::Node& node ); +DLLEXPORT QVariant scalarToVariant( const ::YAML::Node& scalarNode ); +DLLEXPORT QVariantList sequenceToVariant( const ::YAML::Node& sequenceNode ); +DLLEXPORT QVariantMap mapToVariant( const ::YAML::Node& mapNode ); + +/// @brief Returns all the elements of @p listNode in a StringList +DLLEXPORT QStringList toStringList( const ::YAML::Node& listNode ); + +/// @brief Save a @p map to @p filename as YAML +DLLEXPORT bool save( const QString& filename, const QVariantMap& map ); + +/** + * Given an exception from the YAML parser library, explain + * what is going on in terms of the data passed to the parser. + * Uses @p label when labeling the data source (e.g. "netinstall data") + */ +DLLEXPORT void explainException( const ::YAML::Exception& e, const QByteArray& data, const char* label ); +DLLEXPORT void explainException( const ::YAML::Exception& e, const QByteArray& data, const QString& label ); +DLLEXPORT void explainException( const ::YAML::Exception& e, const QByteArray& data ); + +} // namespace YAML +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamares/utils/moc-warnings.h b/calamares/src/libcalamares/utils/moc-warnings.h new file mode 100644 index 0000000..bbe382a --- /dev/null +++ b/calamares/src/libcalamares/utils/moc-warnings.h @@ -0,0 +1,35 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +/* @file Turn off warnings on MOC-generated code + * + * This header file exists **only** to reduce warnings during compilation. + * Code generated by Qt's MOC, in combination with Clang (version 6 or later, + * I'm fairly sure) and the plenty-of-warnings settings that Calamares uses, + * triggers tons of warnings. Since those warnings are not something we + * can do anything about, turn them off by `#include`ing this header + * before a MOC file. + * + * Note that not many files in Calamares use MOC directly: mostly CMake's + * automoc does all the work for us. + */ +#ifdef __clang__ +#include +QT_WARNING_PUSH +QT_WARNING_DISABLE_CLANG( "-Wextra-semi-stmt" ) +QT_WARNING_DISABLE_CLANG( "-Wredundant-parens" ) +QT_WARNING_DISABLE_CLANG( "-Wreserved-identifier" ) + +#if __clang_major__ >= 17 +QT_WARNING_DISABLE_CLANG( "-Wunsafe-buffer-usage" ) +#endif +QT_WARNING_POP +#endif diff --git a/calamares/src/libcalamaresui/Branding.cpp b/calamares/src/libcalamaresui/Branding.cpp new file mode 100644 index 0000000..684a304 --- /dev/null +++ b/calamares/src/libcalamaresui/Branding.cpp @@ -0,0 +1,660 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2018 Raul Rodrigo Segura (raurodse) + * SPDX-FileCopyrightText: 2019 Camilo Higuita + * SPDX-FileCopyrightText: 2021 Anubhav Choudhary + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Branding.h" + +#include "GlobalStorage.h" +#include "utils/Gui.h" +#include "utils/ImageRegistry.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Units.h" +#include "utils/Yaml.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +[[noreturn]] static void +bail( const QString& descriptorPath, const QString& message ) +{ + cError() << "FATAL in" << descriptorPath << Logger::Continuation << Logger::NoQuote << message; + ::exit( EXIT_FAILURE ); +} + +namespace Calamares +{ + +Branding* Branding::s_instance = nullptr; + +Branding* +Branding::instance() +{ + if ( !s_instance ) + { + cWarning() << "No Branding instance created yet."; + } + return s_instance; +} + +// *INDENT-OFF* +// clang-format off +const QStringList Branding::s_stringEntryStrings = +{ + "productName", + "version", + "shortVersion", + "versionedName", + "shortVersionedName", + "shortProductName", + "bootloaderEntryName", + "productUrl", + "supportUrl", + "knownIssuesUrl", + "releaseNotesUrl", + "donateUrl" +}; + +const QStringList Branding::s_imageEntryStrings = +{ + "productBanner", + "productIcon", + "productLogo", + "productWallpaper", + "productWelcome" +}; + +const QStringList Branding::s_uploadServerStrings = +{ + "type", + "url", + "port" +}; +// clang-format on +// *INDENT-ON* + +/** @brief Check that all the entries in the @p style map make sense + * + * This will catch typo's in key names. + */ +static bool +validateStyleEntries( const QMap< QString, QString >& style ) +{ + using SE = Branding::StyleEntry; + + Logger::Once o; + bool valid = true; + + const auto meta = QMetaEnum::fromType< SE >(); + QSet< QString > validNames; + for ( SE i : { SE::SidebarBackground, SE::SidebarBackgroundCurrent, SE::SidebarText, SE::SidebarTextCurrent } ) + { + validNames.insert( meta.valueToKey( i ) ); + } + + for ( const auto& k : style.keys() ) + { + if ( !validNames.contains( k ) ) + { + cWarning() << o << "Unknown branding *style* entry" << k; + valid = false; + } + } + + return valid; +} + +const NamedEnumTable< Branding::WindowDimensionUnit >& +Branding::WindowDimension::suffixes() +{ + using Unit = Branding::WindowDimensionUnit; + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable names{ + {"px", Unit::Pixies}, + {"em", Unit::Fonties} + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +/** @brief Load the @p map with strings from @p doc + * + * Each key-value pair from the sub-map in @p doc identified by @p key + * is inserted into the @p map, but the value is first transformed by + * the @p transform function, which may change strings. + */ +static void +loadStrings( QMap< QString, QString >& map, + const ::YAML::Node& doc, + const std::string& key, + const std::function< QString( const QString& ) >& transform ) +{ + if ( !doc[ key ].IsMap() ) + { + throw ::YAML::Exception( ::YAML::Mark(), std::string( "Branding configuration is not a map: " ) + key ); + } + + const QVariantMap config = Calamares::YAML::mapToVariant( doc[ key ] ); + for ( auto it = config.constBegin(); it != config.constEnd(); ++it ) + { + map.insert( it.key(), transform( it.value().toString() ) ); + } +} + +static Branding::UploadServerInfo +uploadServerFromMap( const QVariantMap& map ) +{ + using Type = Branding::UploadServerType; + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< Type > names { + { "none", Type::None }, + { "fiche", Type::Fiche } + }; + // clang-format on + // *INDENT-ON* + + QString typestring = map[ "type" ].toString(); + QString urlstring = map[ "url" ].toString(); + qint64 sizeLimitKiB = map[ "sizeLimit" ].toLongLong(); + + if ( typestring.isEmpty() || urlstring.isEmpty() ) + { + return Branding::UploadServerInfo { Branding::UploadServerType::None, QUrl(), 0 }; + } + + bool bogus = false; // we don't care about type-name lookup success here + return Branding::UploadServerInfo { names.find( typestring, bogus ), + QUrl( urlstring, QUrl::ParsingMode::StrictMode ), + sizeLimitKiB >= 0 + ? Calamares::KiBtoBytes( static_cast< unsigned long long >( sizeLimitKiB ) ) + : -1 }; +} + +/** @brief Load the @p map with strings from @p config + * + * If os-release is supported (with KF5 CoreAddons >= 5.58) then + * special substitutions can be done as well. See the branding + * documentation for details. + */ + +Branding::Branding( const QString& brandingFilePath, QObject* parent, qreal devicePixelRatio ) + : QObject( parent ) + , m_descriptorPath( brandingFilePath ) + , m_slideshowAPI( 1 ) + , m_welcomeStyleCalamares( false ) + , m_welcomeExpandingLogo( true ) + , m_devicePixelRatio( devicePixelRatio ) +{ + cDebug() << "Using Calamares branding file at" << brandingFilePath; + + QDir componentDir( componentDirectory() ); + if ( !componentDir.exists() ) + { + bail( m_descriptorPath, "Bad component directory path." ); + } + + QFile file( brandingFilePath ); + if ( file.exists() && file.open( QFile::ReadOnly | QFile::Text ) ) + { + QByteArray ba = file.readAll(); + + try + { + auto doc = ::YAML::Load( ba.constData() ); + Q_ASSERT( doc.IsMap() ); + + m_componentName = QString::fromStdString( doc[ "componentName" ].as< std::string >() ); + if ( m_componentName != componentDir.dirName() ) + { + bail( m_descriptorPath, + "The branding component name should match the name of the " + "component directory." ); + } + + initSimpleSettings( doc ); + initSlideshowSettings( doc ); + + // Copy the os-release information into a QHash for use by KMacroExpander. + KOSRelease relInfo; + + QHash< QString, QString > relMap { std::initializer_list< std::pair< QString, QString > > { + { QStringLiteral( "NAME" ), relInfo.name() }, + { QStringLiteral( "VERSION" ), relInfo.version() }, + { QStringLiteral( "ID" ), relInfo.id() }, + { QStringLiteral( "ID_LIKE" ), relInfo.idLike().join( ' ' ) }, + { QStringLiteral( "VERSION_CODENAME" ), relInfo.versionCodename() }, + { QStringLiteral( "VERSION_ID" ), relInfo.versionId() }, + { QStringLiteral( "PRETTY_NAME" ), relInfo.prettyName() }, + { QStringLiteral( "HOME_URL" ), relInfo.homeUrl() }, + { QStringLiteral( "DOCUMENTATION_URL" ), relInfo.documentationUrl() }, + { QStringLiteral( "SUPPORT_URL" ), relInfo.supportUrl() }, + { QStringLiteral( "BUG_REPORT_URL" ), relInfo.bugReportUrl() }, + { QStringLiteral( "PRIVACY_POLICY_URL" ), relInfo.privacyPolicyUrl() }, + { QStringLiteral( "BUILD_ID" ), relInfo.buildId() }, + { QStringLiteral( "VARIANT" ), relInfo.variant() }, + { QStringLiteral( "VARIANT_ID" ), relInfo.variantId() }, + { QStringLiteral( "LOGO" ), relInfo.logo() } } }; + auto expand = [ & ]( const QString& s ) -> QString + { return KMacroExpander::expandMacros( s, relMap, QLatin1Char( '$' ) ); }; + + // Massage the strings, images and style sections. + loadStrings( m_strings, doc, "strings", expand ); + loadStrings( m_images, + doc, + "images", + [ & ]( const QString& s ) -> QString + { + // See also image() + const QString imageName( expand( s ) ); + QFileInfo imageFi( componentDir.absoluteFilePath( imageName ) ); + if ( !imageFi.exists() ) + { + const auto icon = QIcon::fromTheme( imageName ); + // Not found, bail out with the filename used + if ( icon.isNull() ) + { + bail( + m_descriptorPath, + QString( "Image file %1 does not exist." ).arg( imageFi.absoluteFilePath() ) ); + } + return imageName; // Not turned into a path + } + return imageFi.absoluteFilePath(); + } ); + loadStrings( m_style, doc, "style", []( const QString& s ) -> QString { return s; } ); + + m_uploadServer = uploadServerFromMap( Calamares::YAML::mapToVariant( doc[ "uploadServer" ] ) ); + } + catch ( ::YAML::Exception& e ) + { + Calamares::YAML::explainException( e, ba, file.fileName() ); + bail( m_descriptorPath, e.what() ); + } + + QDir translationsDir( componentDir.filePath( "lang" ) ); + if ( !translationsDir.exists() ) + { + cWarning() << "the branding component" << componentDir.absolutePath() << "does not ship translations."; + } + m_translationsPathPrefix = translationsDir.absolutePath(); + m_translationsPathPrefix.append( QString( "%1calamares-%2" ).arg( QDir::separator() ).arg( m_componentName ) ); + } + else + { + cWarning() << "Cannot read branding file" << file.fileName(); + } + + s_instance = this; + if ( m_componentName.isEmpty() ) + { + cWarning() << "Failed to load component from" << brandingFilePath; + } + else + { + cDebug() << "Loaded branding component" << m_componentName; + } + validateStyleEntries( m_style ); +} + +QString +Branding::componentDirectory() const +{ + QFileInfo fi( m_descriptorPath ); + return fi.absoluteDir().absolutePath(); +} + +QString +Branding::string( Branding::StringEntry stringEntry ) const +{ + return m_strings.value( s_stringEntryStrings.value( stringEntry ) ); +} + +QString +Branding::styleString( Branding::StyleEntry styleEntry ) const +{ + const auto meta = QMetaEnum::fromType< Branding::StyleEntry >(); + return m_style.value( meta.valueToKey( styleEntry ) ); +} + +QString +Branding::imagePath( Branding::ImageEntry imageEntry ) const +{ + return m_images.value( s_imageEntryStrings.value( imageEntry ) ); +} + +QPixmap +Branding::image( Branding::ImageEntry imageEntry, const QSize& size ) const +{ + const auto path = imagePath( imageEntry ); + if ( path.contains( '/' ) ) + { + QPixmap pixmap = ImageRegistry::instance()->pixmap( path, size * m_devicePixelRatio ); + pixmap.setDevicePixelRatio( m_devicePixelRatio ); + + Q_ASSERT( !pixmap.isNull() ); + return pixmap; + } + else + { + auto icon = QIcon::fromTheme( path ); + + Q_ASSERT( !icon.isNull() ); + return icon.pixmap( size ); + } +} + +QPixmap +Branding::image( const QString& imageName, const QSize& size ) const +{ + QDir componentDir( componentDirectory() ); + QFileInfo imageFi( componentDir.absoluteFilePath( imageName ) ); + if ( imageFi.exists() ) + { + return ImageRegistry::instance()->pixmap( imageFi.absoluteFilePath(), size ); + } + else + { + const auto icon = QIcon::fromTheme( imageName ); + // Not found, bail out with the filename used + if ( !icon.isNull() ) + { + return icon.pixmap( size ); + } + } + return QPixmap(); +} + +QPixmap +Branding::image( const QStringList& list, const QSize& size ) const +{ + QDir componentDir( componentDirectory() ); + for ( const QString& imageName : list ) + { + auto p = image( imageName, size ); + if ( !p.isNull() ) + { + return p; + } + } + return QPixmap(); +} + +static QString +_stylesheet( const QDir& dir ) +{ + QFileInfo importQSSPath( dir.filePath( "stylesheet.qss" ) ); + if ( importQSSPath.exists() && importQSSPath.isReadable() ) + { + QFile stylesheetFile( importQSSPath.filePath() ); + stylesheetFile.open( QFile::ReadOnly ); + return stylesheetFile.readAll(); + } + else + { + cWarning() << "The branding component" << dir.absolutePath() << "does not ship stylesheet.qss."; + } + return QString(); +} + +QString +Branding::stylesheet() const +{ + return _stylesheet( QFileInfo( m_descriptorPath ).absoluteDir() ); +} + +void +Branding::setGlobals( GlobalStorage* globalStorage ) const +{ + QVariantMap brandingMap; + for ( const QString& key : s_stringEntryStrings ) + { + brandingMap.insert( key, m_strings.value( key ) ); + } + globalStorage->insert( "branding", brandingMap ); +} + +bool +Branding::WindowDimension::isValid() const +{ + return ( unit() != none ) && ( value() > 0 ); +} + +/// @brief Get a string (empty is @p key doesn't exist) from @p key in @p doc +static inline QString +getString( const ::YAML::Node& doc, const char* key ) +{ + if ( doc[ key ] ) + { + return QString::fromStdString( doc[ key ].as< std::string >() ); + } + return QString(); +} + +/// @brief Get a node (throws if @p key doesn't exist) from @p key in @p doc +static inline ::YAML::Node +get( const ::YAML::Node& doc, const char* key ) +{ + auto r = doc[ key ]; + if ( !r.IsDefined() ) + { + throw ::YAML::KeyNotFound( ::YAML::Mark::null_mark(), std::string( key ) ); + } + return r; +} + +static inline void +flavorAndSide( const ::YAML::Node& doc, const char* key, Branding::PanelFlavor& flavor, Branding::PanelSide& side ) +{ + using PanelFlavor = Branding::PanelFlavor; + using PanelSide = Branding::PanelSide; + + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< PanelFlavor > sidebarFlavorNames { + { QStringLiteral( "widget" ), PanelFlavor::Widget }, + { QStringLiteral( "none" ), PanelFlavor::None }, + { QStringLiteral( "hidden" ), PanelFlavor::None } +#ifdef WITH_QML + , + { QStringLiteral( "qml" ), PanelFlavor::Qml } +#endif + }; + static const NamedEnumTable< PanelSide > panelSideNames { + { QStringLiteral( "left" ), PanelSide::Left }, + { QStringLiteral( "right" ), PanelSide::Right }, + { QStringLiteral( "top" ), PanelSide::Top }, + { QStringLiteral( "bottom" ), PanelSide::Bottom } + }; + // clang-format on + // *INDENT-ON* + + bool ok = false; + QString configValue = getString( doc, key ); + if ( configValue.isEmpty() ) + { + // Complain with the original values + cWarning() << "Branding setting for" << key << "is missing, using" << sidebarFlavorNames.find( flavor, ok ) + << panelSideNames.find( side, ok ); + return; + } + + QStringList parts = configValue.split( ',' ); + if ( parts.length() == 1 ) + { + PanelFlavor f = sidebarFlavorNames.find( configValue, ok ); + if ( ok ) + { + flavor = f; + } + else + { + // Complain with the original value + cWarning() << "Branding setting for" << key << "interpreted as" << sidebarFlavorNames.find( flavor, ok ) + << panelSideNames.find( side, ok ); + } + return; + } + + for ( const QString& spart : parts ) + { + bool isFlavor = false; + bool isSide = false; + PanelFlavor f = sidebarFlavorNames.find( spart, isFlavor ); + PanelSide s = panelSideNames.find( spart, isSide ); + if ( isFlavor ) + { + flavor = f; + } + else if ( isSide ) + { + side = s; + } + else + { + cWarning() << "Branding setting for" << key << "contains unknown" << spart << "interpreted as" + << sidebarFlavorNames.find( flavor, ok ) << panelSideNames.find( side, ok ); + return; + } + } +} + +void +Branding::initSimpleSettings( const ::YAML::Node& doc ) +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< WindowExpansion > expansionNames { + { QStringLiteral( "normal" ), WindowExpansion::Normal }, + { QStringLiteral( "fullscreen" ), WindowExpansion::Fullscreen }, + { QStringLiteral( "noexpand" ), WindowExpansion::Fixed } + }; + static const NamedEnumTable< WindowPlacement > placementNames { + { QStringLiteral( "free" ), WindowPlacement::Free }, + { QStringLiteral( "center" ), WindowPlacement::Center } + }; + // clang-format on + // *INDENT-ON* + bool ok = false; + + m_welcomeStyleCalamares = doc[ "welcomeStyleCalamares" ].as< bool >( false ); + m_welcomeExpandingLogo = doc[ "welcomeExpandingLogo" ].as< bool >( true ); + m_windowExpansion = expansionNames.find( getString( doc, "windowExpanding" ), ok ); + if ( !ok ) + { + cWarning() << "Branding module-setting *windowExpanding* interpreted as" + << expansionNames.find( m_windowExpansion, ok ); + } + m_windowPlacement = placementNames.find( getString( doc, "windowPlacement" ), ok ); + if ( !ok ) + { + cWarning() << "Branding module-setting *windowPlacement* interpreted as" + << placementNames.find( m_windowPlacement, ok ); + } + flavorAndSide( doc, "sidebar", m_sidebarFlavor, m_sidebarSide ); + flavorAndSide( doc, "navigation", m_navigationFlavor, m_navigationSide ); + + QString windowSize = getString( doc, "windowSize" ); + if ( !windowSize.isEmpty() ) + { + auto l = windowSize.split( ',' ); + if ( l.count() == 2 ) + { + m_windowWidth = WindowDimension( l[ 0 ] ); + m_windowHeight = WindowDimension( l[ 1 ] ); + } + } + if ( !m_windowWidth.isValid() ) + { + m_windowWidth = WindowDimension( Calamares::windowPreferredWidth, WindowDimensionUnit::Pixies ); + } + if ( !m_windowHeight.isValid() ) + { + m_windowHeight = WindowDimension( Calamares::windowPreferredHeight, WindowDimensionUnit::Pixies ); + } +} + +void +Branding::initSlideshowSettings( const ::YAML::Node& doc ) +{ + QDir componentDir( componentDirectory() ); + + auto slideshow = get( doc, "slideshow" ); + if ( slideshow.IsSequence() ) + { + QStringList slideShowPictures; + slideshow >> slideShowPictures; + for ( int i = 0; i < slideShowPictures.count(); ++i ) + { + QString pathString = slideShowPictures[ i ]; + QFileInfo imageFi( componentDir.absoluteFilePath( pathString ) ); + if ( !imageFi.exists() ) + { + bail( m_descriptorPath, + QString( "Slideshow file %1 does not exist." ).arg( imageFi.absoluteFilePath() ) ); + } + + slideShowPictures[ i ] = imageFi.absoluteFilePath(); + } + + m_slideshowFilenames = slideShowPictures; + m_slideshowAPI = -1; + } +#ifdef WITH_QML + else if ( slideshow.IsScalar() ) + { + QString slideshowPath = QString::fromStdString( slideshow.as< std::string >() ); + QFileInfo slideshowFi( componentDir.absoluteFilePath( slideshowPath ) ); + if ( !slideshowFi.exists() || !slideshowFi.fileName().toLower().endsWith( ".qml" ) ) + { + bail( m_descriptorPath, + QString( "Slideshow file %1 does not exist or is not a valid QML file." ) + .arg( slideshowFi.absoluteFilePath() ) ); + } + m_slideshowPath = slideshowFi.absoluteFilePath(); + + // API choice is relevant for QML slideshow + // TODO:3.3: use get(), make slideshowAPI required + int api + = ( doc[ "slideshowAPI" ] && doc[ "slideshowAPI" ].IsScalar() ) ? doc[ "slideshowAPI" ].as< int >() : -1; + if ( ( api < 1 ) || ( api > 2 ) ) + { + cWarning() << "Invalid or missing *slideshowAPI* in branding file."; + api = 1; + } + m_slideshowAPI = api; + } +#else + else if ( slideshow.IsScalar() ) + { + cWarning() << "Invalid *slideshow* setting, must be list of images."; + } +#endif + else + { + bail( m_descriptorPath, "Syntax error in slideshow sequence." ); + } +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/Branding.h b/calamares/src/libcalamaresui/Branding.h new file mode 100644 index 0000000..3fffa02 --- /dev/null +++ b/calamares/src/libcalamaresui/Branding.h @@ -0,0 +1,326 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2018 Raul Rodrigo Segura (raurodse) + * SPDX-FileCopyrightText: 2019 Camilo Higuita + * SPDX-FileCopyrightText: 2021 Anubhav Choudhary + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef BRANDING_H +#define BRANDING_H + +#include "CalamaresConfig.h" +#include "DllMacro.h" +#include "utils/NamedSuffix.h" + +#include +#include +#include +#include +#include +#include + +namespace YAML +{ +class Node; +} // namespace YAML + +namespace Calamares +{ + +class GlobalStorage; + +class UIDLLEXPORT Branding : public QObject +{ + Q_OBJECT +public: + /** + * Descriptive strings in the configuration file. use + * e.g. *Branding::ProductName to get the string value for + * the product name. + */ + + enum StringEntry + { + ProductName, + Version, + ShortVersion, + VersionedName, + ShortVersionedName, + ShortProductName, + BootloaderEntryName, + ProductUrl, + SupportUrl, + KnownIssuesUrl, + ReleaseNotesUrl, + DonateUrl + }; + Q_ENUM( StringEntry ) + + enum ImageEntry : short + { + ProductBanner, + ProductIcon, + ProductLogo, + ProductWallpaper, + ProductWelcome + }; + Q_ENUM( ImageEntry ) + + /** @brief Names of style entries, for use in code + * + * These names are mapped to names in the branding.desc file through + * an internal table s_styleEntryStrings, which defines which names + * in `branding.desc` key *style* are used for which entry. + */ + enum StyleEntry : short + { + SidebarBackground, + SidebarText, + SidebarTextCurrent, + SidebarBackgroundCurrent, + }; + Q_ENUM( StyleEntry ) + + /** @brief Supported log-upload servers. + * + * 'None' is here as a fallback. + */ + enum UploadServerType : short + { + None, + Fiche + }; + Q_ENUM( UploadServerType ) + + /** @brief Setting for how much the main window may expand. */ + enum class WindowExpansion + { + Normal, + Fullscreen, + Fixed + }; + Q_ENUM( WindowExpansion ) + /** @brief Setting for the main window size. + * + * The units are pixels (Pixies) or something-based-on-fontsize (Fonties), which + * we suffix as "em", e.g. "600px" or "32em". + */ + enum class WindowDimensionUnit + { + None, + Pixies, + Fonties + }; + Q_ENUM( WindowDimensionUnit ) + class WindowDimension : public NamedSuffix< WindowDimensionUnit, WindowDimensionUnit::None > + { + public: + static const NamedEnumTable< WindowDimensionUnit >& suffixes(); + bool isValid() const; + + using NamedSuffix::NamedSuffix; + WindowDimension( const QString& s ) + : NamedSuffix( suffixes(), s ) + { + } + }; + /** @brief Placement of main window. + */ + enum class WindowPlacement + { + Center, + Free + }; + Q_ENUM( WindowPlacement ) + ///@brief What kind of panel (sidebar, navigation) to use in the main window + enum class PanelFlavor + { + None, + Widget +#ifdef WITH_QML + , + Qml +#endif + }; + Q_ENUM( PanelFlavor ) + ///@brief Where to place a panel (sidebar, navigation) + enum class PanelSide + { + None, + Left, + Right, + Top, + Bottom + }; + Q_ENUM( PanelSide ) + + static Branding* instance(); + + explicit Branding( const QString& brandingFilePath, QObject* parent = nullptr, qreal devicePixelRatio = 1.0 ); + + /** @brief Complete path of the branding descriptor file. */ + QString descriptorPath() const { return m_descriptorPath; } + /** @brief The component name found in the descriptor file. + * + * The component name always matches the last directory name in the path. + */ + QString componentName() const { return m_componentName; } + /** @brief The directory holding all of the branding assets. */ + QString componentDirectory() const; + /** @brief The directory where branding translations live. + * + * This is componentDir + "/lang". + */ + QString translationsDirectory() const { return m_translationsPathPrefix; } + + /** @brief Path to the slideshow QML file, if any. (API == 1 or 2)*/ + QString slideshowPath() const { return m_slideshowPath; } + /// @brief List of pathnames of slideshow images, if any. (API == -1) + QStringList slideshowImages() const { return m_slideshowFilenames; } + /** @brief Which slideshow API to use for the slideshow? + * + * - 2 For QML-based slideshows loaded asynchronously (current) + * - 1 For QML-based slideshows, loaded when shown (legacy) + * - -1 For oldschool image-slideshows. + */ + int slideshowAPI() const { return m_slideshowAPI; } + + QPixmap image( Branding::ImageEntry imageEntry, const QSize& size ) const; + + /** @brief Look up an image in the branding directory or as an icon + * + * The @p name is checked in the branding directory: if it is an image + * file, return the pixmap from that file, at the requested size. + * If it isn't a file, look it up as an icon name in the current theme. + * May return a null pixmap if nothing is found. + */ + QPixmap image( const QString& name, const QSize& size ) const; + + /** @brief Look up image with alternate names + * + * Calls image() for each name in the @p list and returns the first + * one that is non-null. May return a null pixmap if nothing is found. + */ + QPixmap image( const QStringList& list, const QSize& size ) const; + + /** @brief Stylesheet to apply for this branding. May be empty. + * + * The file is loaded every time this function is called, so + * it may be quite expensive -- although normally it will be + * called only once, on startup. (Or from the debug window) + */ + QString stylesheet() const; + + bool welcomeStyleCalamares() const { return m_welcomeStyleCalamares; } + bool welcomeExpandingLogo() const { return m_welcomeExpandingLogo; } + bool windowMaximize() const { return m_windowExpansion == WindowExpansion::Fullscreen; } + bool windowExpands() const { return m_windowExpansion != WindowExpansion::Fixed; } + QPair< WindowDimension, WindowDimension > windowSize() const + { + return QPair< WindowDimension, WindowDimension >( m_windowWidth, m_windowHeight ); + } + bool windowPlacementCentered() const { return m_windowPlacement == WindowPlacement::Center; } + + ///@brief Which sidebar flavor is configured + PanelFlavor sidebarFlavor() const { return m_sidebarFlavor; } + ///@brief Which navigation flavor is configured + PanelFlavor navigationFlavor() const { return m_navigationFlavor; } + + /** @brief Upload server configuration + * + * This object has 3 items : the type (which may be none, in which case the URL + * is irrelevant and usually empty), the URL for the upload and the size limit of upload + * in bytes (for configuration value < 0, it serves -1, which stands for having no limit). + */ + struct UploadServerInfo + { + UploadServerType type; + QUrl url; + qint64 size; + + operator bool() const { return type != Calamares::Branding::UploadServerType::None && size != 0; } + }; + UploadServerInfo uploadServer() const { return m_uploadServer; } + + /** + * Creates a map called "branding" in the global storage, and inserts an + * entry for each of the branding strings. This makes the branding + * information accessible to the Python modules. + */ + void setGlobals( GlobalStorage* globalStorage ) const; + +public slots: + QString string( StringEntry stringEntry ) const; + QString versionedName() const { return string( VersionedName ); } + QString productName() const { return string( ProductName ); } + QString shortProductName() const { return string( ShortProductName ); } + QString shortVersionedName() const { return string( ShortVersionedName ); } + + /** @brief Map an enum-value to the entry from the *style* key. + * + * e.g. StyleEntry::SidebarTextCurrent maps to the corresponding + * *style* entry, which is (confusingly) named "sidebarTextSelect" + * in the branding file. + */ + QString styleString( StyleEntry styleEntry ) const; + QString imagePath( ImageEntry imageEntry ) const; + + PanelSide sidebarSide() const { return m_sidebarSide; } + PanelSide navigationSide() const { return m_navigationSide; } + +private: + static Branding* s_instance; + + static const QStringList s_stringEntryStrings; + static const QStringList s_imageEntryStrings; + static const QStringList s_uploadServerStrings; + + QString m_descriptorPath; // Path to descriptor (e.g. "/etc/calamares/default/branding.desc") + QString m_componentName; // Matches last part of full path to containing directory + QMap< QString, QString > m_strings; + QMap< QString, QString > m_images; + QMap< QString, QString > m_style; + UploadServerInfo m_uploadServer; + + /* The slideshow can be done in one of two ways: + * - as a sequence of images + * - as a QML file + * The slideshow: setting selects which one is used. If it is + * a list (of filenames) then it is a sequence of images, and otherwise + * it is a QML file which is run. (For QML, the slideshow API is + * important). + */ + QStringList m_slideshowFilenames; + QString m_slideshowPath; + int m_slideshowAPI; + QString m_translationsPathPrefix; + + /** @brief Initialize the simple settings below */ + void initSimpleSettings( const YAML::Node& doc ); + ///@brief Initialize the slideshow settings, above + void initSlideshowSettings( const YAML::Node& doc ); + + bool m_welcomeStyleCalamares; + bool m_welcomeExpandingLogo; + + WindowExpansion m_windowExpansion; + WindowDimension m_windowHeight, m_windowWidth; + WindowPlacement m_windowPlacement; + + PanelFlavor m_sidebarFlavor = PanelFlavor::Widget; + PanelFlavor m_navigationFlavor = PanelFlavor::Widget; + PanelSide m_sidebarSide = PanelSide::Left; + PanelSide m_navigationSide = PanelSide::Bottom; + + qreal m_devicePixelRatio; +}; + +} // namespace Calamares + +#endif // BRANDING_H diff --git a/calamares/src/libcalamaresui/CMakeLists.txt b/calamares/src/libcalamaresui/CMakeLists.txt new file mode 100644 index 0000000..ad2e2ea --- /dev/null +++ b/calamares/src/libcalamaresui/CMakeLists.txt @@ -0,0 +1,90 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# libcalamaresui is the GUI part of Calamares, which includes handling +# view modules, view steps, widgets, and branding. + +# The UI libs use the non-UI library +include_directories(${CMAKE_SOURCE_DIR}/src/libcalamares ${CMAKE_BINARY_DIR}/src/libcalamares ${CMAKE_SOURCE_DIR}) + +set(calamaresui_SOURCES + modulesystem/CppJobModule.cpp + modulesystem/ModuleFactory.cpp + modulesystem/ModuleManager.cpp + modulesystem/ProcessJobModule.cpp + modulesystem/ViewModule.cpp + utils/Gui.cpp + utils/ImageRegistry.cpp + utils/Paste.cpp + viewpages/BlankViewStep.cpp + viewpages/ExecutionViewStep.cpp + viewpages/Slideshow.cpp + viewpages/ViewStep.cpp + widgets/ClickableLabel.cpp + widgets/ErrorDialog.cpp + widgets/FixedAspectRatioLabel.cpp + widgets/PrettyRadioButton.cpp + widgets/LogWidget.cpp + widgets/TranslationFix.cpp + widgets/WaitingWidget.cpp + widgets/waitingspinnerwidget.cpp + Branding.cpp + ViewManager.cpp +) + +if(WITH_PYTHON) + list(APPEND calamaresui_SOURCES modulesystem/PythonJobModule.cpp) +endif() + +if(WITH_QML) + list(APPEND calamaresui_SOURCES utils/Qml.cpp viewpages/QmlViewStep.cpp) +endif() + +calamares_add_library(calamaresui + SOURCES ${calamaresui_SOURCES} + TARGET_TYPE SHARED + EXPORT_MACRO UIDLLEXPORT_PRO + LINK_LIBRARIES + ${qtname}::Svg + RESOURCES libcalamaresui.qrc + EXPORT Calamares + UI + utils/ErrorDialog/ErrorDialog.ui + VERSION ${CALAMARES_VERSION_SHORT} + SOVERSION ${CALAMARES_SOVERSION} +) +target_link_libraries(calamaresui PRIVATE yamlcpp::yamlcpp) +target_link_libraries(calamaresui PRIVATE ${kfname}::CoreAddons) + +if(WITH_QML) + target_link_libraries(calamaresui PUBLIC ${qtname}::QuickWidgets) +endif() + +set_target_properties( + calamaresui + PROPERTIES + CXX_VISIBILITY_PRESET hidden +) + +add_library(Calamares::calamaresui ALIAS calamaresui) + +### Installation +# +# +# The library is already installed through calamares_add_library(), +# so we only need to do headers. Unlike the Calamares source tree, +# where libcalamares and libcalamaresui live in different branches, +# we're going to glom it all together in the installed headers location. + +install(FILES Branding.h ViewManager.h DESTINATION include/libcalamares) + +# Install each subdir-worth of header files +foreach(subdir modulesystem utils viewpages widgets) + file(GLOB subdir_headers "${subdir}/*.h") + install(FILES ${subdir_headers} DESTINATION include/libcalamares/${subdir}) +endforeach() + +calamares_add_test(test_libcalamaresuipaste SOURCES utils/TestPaste.cpp utils/Paste.cpp LIBRARIES calamaresui) diff --git a/calamares/src/libcalamaresui/ViewManager.cpp b/calamares/src/libcalamaresui/ViewManager.cpp new file mode 100644 index 0000000..0e8c453 --- /dev/null +++ b/calamares/src/libcalamaresui/ViewManager.cpp @@ -0,0 +1,650 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Dominic Hayes + * SPDX-FileCopyrightText: 2019 Gabriel Craciunescu + * SPDX-FileCopyrightText: 2021 Anubhav Choudhary + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ViewManager.h" + +#include "Branding.h" +#include "JobQueue.h" +#include "Settings.h" + +#include "utils/Logger.h" +#include "utils/Paste.h" +#include "utils/Retranslator.h" +#include "utils/String.h" +#include "viewpages/BlankViewStep.h" +#include "viewpages/ExecutionViewStep.h" +#include "viewpages/ViewStep.h" +#include "widgets/ErrorDialog.h" +#include "widgets/TranslationFix.h" + +#include +#include +#include +#include +#include +#include +#include + +#define UPDATE_BUTTON_PROPERTY( name, value ) \ + do \ + { \ + m_##name = value; \ + emit name##Changed( m_##name ); \ + } while ( false ) + +namespace Calamares +{ + +ViewManager* ViewManager::s_instance = nullptr; + +ViewManager* +ViewManager::instance() +{ + return s_instance; +} + +ViewManager* +ViewManager::instance( QObject* parent ) +{ + Q_ASSERT( !s_instance ); + s_instance = new ViewManager( parent ); + return s_instance; +} + +ViewManager::ViewManager( QObject* parent ) + : QAbstractListModel( parent ) + , m_currentStep( -1 ) + , m_widget( new QWidget() ) + , m_panelSides( Qt::Horizontal | Qt::Vertical ) +{ + Q_ASSERT( !s_instance ); + + QBoxLayout* mainLayout = new QVBoxLayout; + mainLayout->setContentsMargins( 0, 0, 0, 0 ); + m_widget->setObjectName( "viewManager" ); + m_widget->setLayout( mainLayout ); + + m_stack = new QStackedWidget( m_widget ); + m_stack->setObjectName( "viewManagerStack" ); + m_stack->setContentsMargins( 0, 0, 0, 0 ); + mainLayout->addWidget( m_stack ); + + updateButtonLabels(); + + connect( JobQueue::instance(), &JobQueue::failed, this, &ViewManager::onInstallationFailed ); + connect( JobQueue::instance(), &JobQueue::finished, this, &ViewManager::next ); + + CALAMARES_RETRANSLATE_SLOT( &ViewManager::updateButtonLabels ); + +#ifdef PRESERVE_FOR_TRANSLATION_PURPOSES + tr( "&Yes" ); + tr( "&No" ); + tr( "&Close" ); +#endif +} + +ViewManager::~ViewManager() +{ + m_widget->deleteLater(); + s_instance = nullptr; +} + +QWidget* +ViewManager::centralWidget() +{ + return m_widget; +} + +void +ViewManager::addViewStep( ViewStep* step ) +{ + insertViewStep( m_steps.size(), step ); + // If this is the first inserted view step, update status of "Next" button + if ( m_steps.count() == 1 ) + { + m_nextEnabled = step->isNextEnabled(); + emit nextEnabledChanged( m_nextEnabled ); + } +} + +void +ViewManager::insertViewStep( int before, ViewStep* step ) +{ + emit beginInsertRows( QModelIndex(), before, before ); + m_steps.insert( before, step ); + connect( step, &ViewStep::ensureSize, this, &ViewManager::ensureSize ); + connect( step, &ViewStep::nextStatusChanged, this, &ViewManager::updateNextStatus ); + + if ( !step->widget() ) + { + cError() << "ViewStep" << step->moduleInstanceKey() << "has no widget."; + } + else + { + QLayout* layout = step->widget()->layout(); + if ( layout ) + { + const auto margins = step->widgetMargins( m_panelSides ); + layout->setContentsMargins( margins.width(), margins.height(), margins.width(), margins.height() ); + } + + m_stack->insertWidget( before, step->widget() ); + m_stack->setCurrentIndex( 0 ); + step->widget()->setFocus(); + } + emit endInsertRows(); +} + +void +ViewManager::onInstallationFailed( const QString& message, const QString& details ) +{ + cError() << "Installation failed:" << message; + cDebug() << Logger::SubEntry << "- message:" << message; + cDebug() << Logger::SubEntry << "- details:" << Logger::NoQuote << details; + + QString heading = Calamares::Settings::instance()->isSetupMode() ? tr( "Setup Failed", "@title" ) + : tr( "Installation Failed", "@title" ); + + ErrorDialog* errorDialog = new ErrorDialog(); + errorDialog->setWindowTitle( tr( "Error", "@title" ) ); + errorDialog->setHeading( "" + heading + "" ); + errorDialog->setInformativeText( message ); + errorDialog->setShouldOfferWebPaste( Calamares::Branding::instance()->uploadServer() ); + errorDialog->setDetails( details ); + errorDialog->show(); + + cDebug() << "Calamares will quit when the dialog closes."; + connect( errorDialog, + &QDialog::finished, + [ errorDialog ]( int result ) + { + if ( result == QDialog::Accepted && errorDialog->shouldOfferWebPaste() ) + { + Calamares::Paste::doLogUploadUI( errorDialog ); + } +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + QApplication::quit(); +#else + QApplication::exit( EXIT_SUCCESS ); +#endif + } ); +} + +void +ViewManager::onInitFailed( const QStringList& modules ) +{ + // Because this means the installer / setup program is broken by the distributor, + // don't bother being precise about installer / setup wording. + QString title( tr( "Calamares Initialization Failed", "@title" ) ); + QString description( tr( "%1 can not be installed. Calamares was unable to load all of the configured modules. " + "This is a problem with the way Calamares is being used by the distribution.", + "@info" ) ); + QString detailString; + + if ( modules.count() > 0 ) + { + description.append( tr( "
The following modules could not be loaded:", "@info" ) ); + QStringList details; + details << QLatin1String( "
    " ); + for ( const auto& m : modules ) + { + details << QLatin1String( "
  • " ) << m << QLatin1String( "
  • " ); + } + details << QLatin1String( "
" ); + detailString = details.join( QString() ); + } + + insertViewStep( + 0, + new BlankViewStep( title, description.arg( Calamares::Branding::instance()->productName() ), detailString ) ); +} + +void +ViewManager::onInitComplete() +{ + m_currentStep = 0; + + // Tell the first view that it's been shown. + if ( m_steps.count() > 0 ) + { + m_steps.first()->onActivate(); + } + + emit currentStepChanged(); +} + +void +ViewManager::updateNextStatus( bool status ) +{ + ViewStep* vs = qobject_cast< ViewStep* >( sender() ); + if ( vs && currentStepValid() ) + { + if ( vs == m_steps.at( m_currentStep ) ) + { + m_nextEnabled = status; + emit nextEnabledChanged( m_nextEnabled ); + } + } +} + +ViewStepList +ViewManager::viewSteps() const +{ + return m_steps; +} + +ViewStep* +ViewManager::currentStep() const +{ + return currentStepValid() ? m_steps.value( m_currentStep ) : nullptr; +} + +int +ViewManager::currentStepIndex() const +{ + return m_currentStep; +} + +/** @brief Is the given step at @p index an execution step? + * + * Returns true if the step is an execution step, false otherwise. + * Also returns false if the @p index is out of range. + */ +static inline bool +stepIsExecute( const ViewStepList& steps, int index ) +{ + return ( 0 <= index ) && ( index < steps.count() ) + && ( qobject_cast< ExecutionViewStep* >( steps.at( index ) ) != nullptr ); +} + +static inline bool +isAtVeryEnd( const ViewStepList& steps, int index ) +{ + // If we have an empty list, then there's no point right now + // in checking if we're at the end. + if ( steps.count() == 0 ) + { + return false; + } + // .. and if the index is invalid, ignore it too + if ( !( ( 0 <= index ) && ( index < steps.count() ) ) ) + { + return false; + } + return ( index >= steps.count() ) || ( index == steps.count() - 1 && steps.last()->isAtEnd() ); +} + +static int +questionBox( QWidget* parent, + const QString& title, + const QString& question, + const QString& button0, + const QString& button1 ) +{ + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + return QMessageBox::question( parent, + title, + question, + button0, + button1, + QString(), + 0 /* default first button, i.e. confirm */, + 1 /* escape is second button, i.e. cancel */ ); +#else + QMessageBox mb( QMessageBox::Question, title, question, QMessageBox::StandardButton::NoButton, parent ); + const auto* const okButton = mb.addButton( button0, QMessageBox::AcceptRole ); + mb.addButton( button1, QMessageBox::RejectRole ); + mb.exec(); + if ( mb.clickedButton() == okButton ) + { + return 0; + } + return 1; // Cancel +#endif +} + +void +ViewManager::next() +{ + if ( !currentStepValid() ) + { + return; + } + + ViewStep* step = m_steps.at( m_currentStep ); + bool executing = false; + if ( step->isAtEnd() ) + { + const auto* const settings = Calamares::Settings::instance(); + + // Special case when the user clicks next on the very last page in a view phase + // and right before switching to an execution phase. + // Depending on Calamares::Settings, we show an "are you sure" prompt or not. + if ( settings->showPromptBeforeExecution() && stepIsExecute( m_steps, m_currentStep + 1 ) ) + { + QString title = settings->isSetupMode() ? tr( "Continue with Setup?", "@title" ) + : tr( "Continue with Installation?", "@title" ); + QString question = settings->isSetupMode() + ? tr( "The %1 setup program is about to make changes to your " + "disk in order to set up %2.
You will not be able " + "to undo these changes.", + "%1 is short product name, %2 is short product name with version" ) + : tr( "The %1 installer is about to make changes to your " + "disk in order to install %2.
You will not be able " + "to undo these changes.", + "%1 is short product name, %2 is short product name with version" ); + QString confirm + = settings->isSetupMode() ? tr( "&Set Up Now", "@button" ) : tr( "&Install Now", "@button" ); + + const auto* branding = Calamares::Branding::instance(); + int reply = questionBox( m_widget, + title, + question.arg( branding->shortProductName(), branding->shortVersionedName() ), + confirm, + tr( "Go &Back", "@button" ) ); + if ( reply == 1 ) + { + return; + } + } + + m_currentStep++; + + m_stack->setCurrentIndex( m_currentStep ); // Does nothing if out of range + step->onLeave(); + + if ( m_currentStep < m_steps.count() ) + { + m_steps.at( m_currentStep )->onActivate(); + executing = qobject_cast< ExecutionViewStep* >( m_steps.at( m_currentStep ) ) != nullptr; + emit currentStepChanged(); + } + else + { + // Reached the end in a weird state (e.g. no finished step after an exec) + executing = false; + UPDATE_BUTTON_PROPERTY( nextEnabled, false ); + UPDATE_BUTTON_PROPERTY( backEnabled, false ); + } + updateCancelEnabled( !settings->disableCancel() && !( executing && settings->disableCancelDuringExec() ) ); + updateBackAndNextVisibility( !( executing && settings->hideBackAndNextDuringExec() ) ); + } + else + { + step->next(); + } + + if ( m_currentStep < m_steps.count() ) + { + UPDATE_BUTTON_PROPERTY( nextEnabled, !executing && m_steps.at( m_currentStep )->isNextEnabled() ); + UPDATE_BUTTON_PROPERTY( backEnabled, !executing && m_steps.at( m_currentStep )->isBackEnabled() ); + } + + updateButtonLabels(); +} + +void +ViewManager::updateButtonLabels() +{ + const auto* const settings = Calamares::Settings::instance(); + + QString nextIsInstallationStep = settings->isSetupMode() ? tr( "&Set Up", "@button" ) : tr( "&Install", "@button" ); + QString quitOnCompleteTooltip = settings->isSetupMode() + ? tr( "Setup is complete. Close the setup program.", "@tooltip" ) + : tr( "The installation is complete. Close the installer.", "@tooltip" ); + QString cancelBeforeInstallationTooltip = settings->isSetupMode() + ? tr( "Cancel the setup process without changing the system.", "@tooltip" ) + : tr( "Cancel the installation process without changing the system.", "@tooltip" ); + + // If we're going into the execution step / install phase, other message + if ( stepIsExecute( m_steps, m_currentStep + 1 ) ) + { + UPDATE_BUTTON_PROPERTY( nextLabel, nextIsInstallationStep ); + UPDATE_BUTTON_PROPERTY( nextIcon, "run-install" ); + } + else + { + UPDATE_BUTTON_PROPERTY( nextLabel, tr( "&Next", "@button" ) ); + UPDATE_BUTTON_PROPERTY( nextIcon, "go-next" ); + } + + // Going back is always simple + UPDATE_BUTTON_PROPERTY( backLabel, tr( "&Back", "@button" ) ); + UPDATE_BUTTON_PROPERTY( backIcon, "go-previous" ); + + // Cancel button changes label at the end + if ( isAtVeryEnd( m_steps, m_currentStep ) ) + { + UPDATE_BUTTON_PROPERTY( quitLabel, tr( "&Done", "@button" ) ); + UPDATE_BUTTON_PROPERTY( quitTooltip, quitOnCompleteTooltip ); + UPDATE_BUTTON_PROPERTY( quitVisible, true ); + UPDATE_BUTTON_PROPERTY( quitIcon, "dialog-ok-apply" ); + updateCancelEnabled( true ); + if ( settings->quitAtEnd() ) + { + quit(); + } + } + else + { + if ( settings->disableCancel() ) + { + UPDATE_BUTTON_PROPERTY( quitVisible, false ); + } + updateCancelEnabled( !settings->disableCancel() + && !( stepIsExecute( m_steps, m_currentStep ) && settings->disableCancelDuringExec() ) ); + + UPDATE_BUTTON_PROPERTY( quitLabel, tr( "&Cancel", "@button" ) ); + UPDATE_BUTTON_PROPERTY( quitTooltip, cancelBeforeInstallationTooltip ); + UPDATE_BUTTON_PROPERTY( quitIcon, "dialog-cancel" ); + } +} + +void +ViewManager::back() +{ + if ( !currentStepValid() ) + { + return; + } + + ViewStep* step = m_steps.at( m_currentStep ); + if ( step->isAtBeginning() && m_currentStep > 0 ) + { + m_currentStep--; + m_stack->setCurrentIndex( m_currentStep ); + step->onLeave(); + m_steps.at( m_currentStep )->onActivate(); + emit currentStepChanged(); + } + else if ( !step->isAtBeginning() ) + { + step->back(); + } + else + { + return; + } + + UPDATE_BUTTON_PROPERTY( nextEnabled, m_steps.at( m_currentStep )->isNextEnabled() ); + UPDATE_BUTTON_PROPERTY( backEnabled, + ( m_currentStep == 0 && m_steps.first()->isAtBeginning() ) + ? false + : m_steps.at( m_currentStep )->isBackEnabled() ); + + updateButtonLabels(); +} + +void +ViewManager::quit() +{ + const auto r = confirmCancelInstallation(); + if ( r == Confirmation::Continue ) + { + return; + } + + if ( r == Confirmation::CancelInstallation ) + { + // Cancel view steps in reverse + for ( int i = m_currentStep; i >= 0; --i ) + { + auto* step = m_steps.at( i ); + cDebug() << "Cancelling view step" << step->moduleInstanceKey(); + step->onCancel(); + } + } + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + QApplication::quit(); +#else + QApplication::exit( EXIT_SUCCESS ); +#endif +} + +ViewManager::Confirmation +ViewManager::confirmCancelInstallation() +{ + const auto* const settings = Calamares::Settings::instance(); + + // When we're at the very end, then it's always OK to exit. + if ( isAtVeryEnd( m_steps, m_currentStep ) ) + { + return Confirmation::EndOfInstallation; + } + + // Not at the very end, cancel/quit might be disabled + if ( settings->disableCancel() ) + { + return Confirmation::Continue; + } + if ( settings->disableCancelDuringExec() && stepIsExecute( m_steps, m_currentStep ) ) + { + return Confirmation::Continue; + } + + // Otherwise, confirm cancel/quit. + QString title = settings->isSetupMode() ? tr( "Cancel Setup?", "@title" ) : tr( "Cancel Installation?", "@title" ); + QString question = settings->isSetupMode() ? tr( "Do you really want to cancel the current setup process?\n" + "The setup program will quit and all changes will be lost." ) + : tr( "Do you really want to cancel the current install process?\n" + "The installer will quit and all changes will be lost." ); + QMessageBox mb( QMessageBox::Question, title, question, QMessageBox::Yes | QMessageBox::No, m_widget ); + mb.setDefaultButton( QMessageBox::No ); + Calamares::fixButtonLabels( &mb ); + int response = mb.exec(); + return ( response == QMessageBox::Yes ) ? Confirmation::CancelInstallation : Confirmation::Continue; +} + +void +ViewManager::updateCancelEnabled( bool enabled ) +{ + UPDATE_BUTTON_PROPERTY( quitEnabled, enabled ); + emit cancelEnabled( enabled ); +} + +void +ViewManager::updateBackAndNextVisibility( bool visible ) +{ + UPDATE_BUTTON_PROPERTY( backAndNextVisible, visible ); +} + +QVariant +ViewManager::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() ) + { + return QVariant(); + } + + if ( ( index.row() < 0 ) || ( index.row() >= m_steps.length() ) ) + { + return QVariant(); + } + + const auto* step = m_steps.at( index.row() ); + if ( !step ) + { + return QVariant(); + } + + switch ( role ) + { + case Qt::DisplayRole: + return step->prettyName(); + case Qt::ToolTipRole: + if ( Calamares::Settings::instance()->debugMode() ) + { + auto key = step->moduleInstanceKey(); + // The tooltip is intentionally not translated: + // we must be in debug-mode (-d) so presumably it + // is a distro-developer or Calamares-developer + // running it, and we don't need translation for them. + QString toolTip( "Debug information" ); // Intentionally no translation here + toolTip.append( "
Type:\tViewStep" ); + toolTip.append( QString( "
Pretty:\t%1" ).arg( step->prettyName() ) ); + toolTip.append( QString( "
Status:\t%1" ).arg( step->prettyStatus() ) ); + toolTip.append( + QString( "
Source:\t%1" ).arg( key.isValid() ? key.toString() : QStringLiteral( "built-in" ) ) ); + return toolTip; + } + else + { + return QVariant(); + } + case ProgressTreeItemCurrentIndex: + return m_currentStep; + default: + return QVariant(); + } +} + +int +ViewManager::rowCount( const QModelIndex& parent ) const +{ + if ( parent.column() > 0 ) + { + return 0; + } + return m_steps.length(); +} + +bool +ViewManager::isChrootMode() const +{ + const auto* s = Settings::instance(); + return s ? s->doChroot() : true; +} + +bool +ViewManager::isDebugMode() const +{ + const auto* s = Settings::instance(); + return s ? s->debugMode() : false; +} + +bool +ViewManager::isSetupMode() const +{ + const auto* s = Settings::instance(); + return s ? s->isSetupMode() : false; +} + +QString +ViewManager::logFilePath() const +{ + return Logger::logFile(); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/ViewManager.h b/calamares/src/libcalamaresui/ViewManager.h new file mode 100644 index 0000000..62d2b9f --- /dev/null +++ b/calamares/src/libcalamaresui/ViewManager.h @@ -0,0 +1,296 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef VIEWMANAGER_H +#define VIEWMANAGER_H + +#include "DllMacro.h" +#include "viewpages/ViewStep.h" + +#include +#include +#include +#include + +namespace Calamares +{ +/** + * @brief The ViewManager class handles progression through view pages. + * @note Singleton object, only use through ViewManager::instance(). + */ +class UIDLLEXPORT ViewManager : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY( int currentStepIndex READ currentStepIndex NOTIFY currentStepChanged FINAL ) + + Q_PROPERTY( bool nextEnabled READ nextEnabled NOTIFY nextEnabledChanged FINAL ) + Q_PROPERTY( QString nextLabel READ nextLabel NOTIFY nextLabelChanged FINAL ) + Q_PROPERTY( QString nextIcon READ nextIcon NOTIFY nextIconChanged FINAL ) + + Q_PROPERTY( bool backEnabled READ backEnabled NOTIFY backEnabledChanged FINAL ) + Q_PROPERTY( QString backLabel READ backLabel NOTIFY backLabelChanged FINAL ) + Q_PROPERTY( QString backIcon READ backIcon NOTIFY backIconChanged FINAL ) + + Q_PROPERTY( bool quitEnabled READ quitEnabled NOTIFY quitEnabledChanged FINAL ) + Q_PROPERTY( QString quitLabel READ quitLabel NOTIFY quitLabelChanged FINAL ) + Q_PROPERTY( QString quitIcon READ quitIcon NOTIFY quitIconChanged FINAL ) + Q_PROPERTY( QString quitTooltip READ quitTooltip NOTIFY quitTooltipChanged FINAL ) + + Q_PROPERTY( bool quitVisible READ quitVisible NOTIFY quitVisibleChanged FINAL ) + + Q_PROPERTY( bool backAndNextVisible READ backAndNextVisible NOTIFY backAndNextVisibleChanged FINAL ) + + ///@brief Sides on which the ViewManager has side-panels + Q_PROPERTY( Qt::Orientations panelSides READ panelSides WRITE setPanelSides MEMBER m_panelSides ) + + // Global properties, where ViewManager proxies to Settings + Q_PROPERTY( bool isDebugMode READ isDebugMode CONSTANT FINAL ) + Q_PROPERTY( bool isChrootMode READ isChrootMode CONSTANT FINAL ) + Q_PROPERTY( bool isSetupMode READ isSetupMode CONSTANT FINAL ) + Q_PROPERTY( QString logFilePath READ logFilePath CONSTANT FINAL ) + +public: + /** + * @brief instance access to the ViewManager singleton. + * @return pointer to the singleton instance. + */ + static ViewManager* instance(); + static ViewManager* instance( QObject* parent ); + + /** + * @brief centralWidget always returns the central widget in the Calamares main + * window. + * @return a pointer to the active QWidget (usually a wizard page provided by a + * view module). + */ + QWidget* centralWidget(); + + /** + * @brief addViewStep appends a view step to the roster. + * @param step a pointer to the ViewStep object to add. + * @note a ViewStep is the active instance of a view module, it aggregates one + * or more view pages, plus zero or more jobs which may be created at runtime. + */ + void addViewStep( ViewStep* step ); + + /** + * @brief viewSteps returns the list of currently present view steps. + * @return the ViewStepList. + * This should only return an empty list before startup is complete. + */ + ViewStepList viewSteps() const; + + /** + * @brief currentStep returns the currently active ViewStep, i.e. the ViewStep + * which owns the currently visible view page. + * @return the active ViewStep. Do not confuse this with centralWidget(). + * @see ViewStep::centralWidget + */ + ViewStep* currentStep() const; + + /** + * @brief currentStepIndex returns the index of the currently active ViewStep. + * @return the index. + */ + int currentStepIndex() const; + + enum class Confirmation + { + Continue, // User rejects cancel / close question + CancelInstallation, // User accepts cancel / close question + EndOfInstallation, // There was no question because the installation was already done + }; + + /** + * @brief Called when "Cancel" is clicked; asks for confirmation. + * Other means of closing Calamares also call this method, e.g. alt-F4. + * At the end of installation, no confirmation is asked. + * + * @return a Confirmation value, @c Unasked if the installation is complete + */ + Confirmation confirmCancelInstallation(); + + Qt::Orientations panelSides() const { return m_panelSides; } + void setPanelSides( Qt::Orientations panelSides ) { m_panelSides = panelSides; } + +public Q_SLOTS: + /** + * @brief next moves forward to the next page of the current ViewStep (if any), + * or to the first page of the next ViewStep if the current ViewStep doesn't + * have any more pages. + */ + void next(); + bool nextEnabled() const + { + return m_nextEnabled; ///< Is the next-button to be enabled + } + QString nextLabel() const + { + return m_nextLabel; ///< What should be displayed on the next-button + } + QString nextIcon() const + { + return m_nextIcon; ///< Name of the icon to show + } + + /** + * @brief back moves backward to the previous page of the current ViewStep (if any), + * or to the last page of the previous ViewStep if the current ViewStep doesn't + * have any pages before the current one. + */ + void back(); + bool backEnabled() const + { + return m_backEnabled; ///< Is the back-button to be enabled + } + QString backLabel() const + { + return m_backLabel; ///< What should be displayed on the back-button + } + QString backIcon() const + { + return m_backIcon; ///< Name of the icon to show + } + + bool backAndNextVisible() const + { + return m_backAndNextVisible; ///< Is the quit-button to be enabled + } + /** + * @brief Probably quit + * + * Asks for confirmation if necessary. Terminates the application. + */ + void quit(); + bool quitEnabled() const + { + return m_quitEnabled; ///< Is the quit-button to be enabled + } + QString quitLabel() const + { + return m_quitLabel; ///< What should be displayed on the quit-button + } + QString quitIcon() const + { + return m_quitIcon; ///< Name of the icon to show + } + bool quitVisible() const + { + return m_quitVisible; ///< Should the quit-button be visible + } + QString quitTooltip() const { return m_quitTooltip; } + + /** + * @brief onInstallationFailed displays an error message when a fatal failure + * happens in a ViewStep. + * @param message the error string. + * @param details the details string. + */ + void onInstallationFailed( const QString& message, const QString& details ); + + /** @brief Replaces the stack with a view step stating that initialization failed. + * + * @param modules a list of failed modules. + */ + void onInitFailed( const QStringList& modules ); + + /** @brief Tell the manager that initialization / loading is complete. + * + * Call this at least once, to tell the manager to activate the first page. + */ + void onInitComplete(); + + /// @brief Connected to ViewStep::nextStatusChanged for all steps + void updateNextStatus( bool enabled ); + + /// @brief Proxy to Settings::debugMode() default @c false + bool isDebugMode() const; + /// @brief Proxy to Settings::doChroot() default @c true + bool isChrootMode() const; + /// @brief Proxy to Settings::isSetupMode() default @c false + bool isSetupMode() const; + /// @brief Proxy to Logger::logFile(), default empty + QString logFilePath() const; + +signals: + void currentStepChanged(); + void ensureSize( QSize size ) const; // See ViewStep::ensureSize() + void cancelEnabled( bool enabled ) const; + + void nextEnabledChanged( bool ) const; + void nextLabelChanged( QString ) const; + void nextIconChanged( QString ) const; + + void backEnabledChanged( bool ) const; + void backLabelChanged( QString ) const; + void backIconChanged( QString ) const; + void backAndNextVisibleChanged( bool ) const; + + void quitEnabledChanged( bool ) const; + void quitLabelChanged( QString ) const; + void quitIconChanged( QString ) const; + void quitVisibleChanged( bool ) const; + void quitTooltipChanged( QString ) const; + +private: + explicit ViewManager( QObject* parent = nullptr ); + ~ViewManager() override; + + void insertViewStep( int before, ViewStep* step ); + void updateButtonLabels(); + void updateCancelEnabled( bool enabled ); + void updateBackAndNextVisibility( bool visible ); + + inline bool currentStepValid() const { return ( 0 <= m_currentStep ) && ( m_currentStep < m_steps.length() ); } + + static ViewManager* s_instance; + + ViewStepList m_steps; + int m_currentStep; + + QWidget* m_widget; + QStackedWidget* m_stack; + + bool m_nextEnabled = false; + QString m_nextLabel; + QString m_nextIcon; ///< Name of icon to show on button + + bool m_backEnabled = false; + QString m_backLabel; + QString m_backIcon; + + bool m_backAndNextVisible = true; + + bool m_quitEnabled = false; + QString m_quitLabel; + QString m_quitIcon; + QString m_quitTooltip; + bool m_quitVisible = true; + + Qt::Orientations m_panelSides; + +public: + /** @section Model + * + * These are the methods and enums used for the as-a-model part + * of the ViewManager. + */ + enum Role + { + ProgressTreeItemCurrentIndex = Qt::UserRole + 13 ///< Index (row) of the current step + }; + + QVariant data( const QModelIndex& index, int role = Qt::DisplayRole ) const override; + int rowCount( const QModelIndex& parent = QModelIndex() ) const override; +}; + +} // namespace Calamares + +#endif // VIEWMANAGER_H diff --git a/calamares/src/libcalamaresui/libcalamaresui.qrc b/calamares/src/libcalamaresui/libcalamaresui.qrc new file mode 100644 index 0000000..62a7df2 --- /dev/null +++ b/calamares/src/libcalamaresui/libcalamaresui.qrc @@ -0,0 +1,28 @@ + + + + + ../../data/images/yes.svgz + ../../data/images/no.svgz + ../../data/images/information.svgz + ../../data/images/fail.svgz + ../../data/images/bugs.svg + ../../data/images/help.svg + ../../data/images/release.svg + ../../data/images/help-donate.svg + ../../data/images/partition-disk.svg + ../../data/images/partition-partition.svg + ../../data/images/partition-alongside.svg + ../../data/images/partition-erase-auto.svg + ../../data/images/partition-manual.svg + ../../data/images/partition-replace-os.svg + ../../data/images/boot-environment.svg + ../../data/images/partition-table.svg + ../../data/images/squid.svg + ../../data/images/state-ok.svg + ../../data/images/state-warning.svg + ../../data/images/state-error.svg + + diff --git a/calamares/src/libcalamaresui/modulesystem/CppJobModule.cpp b/calamares/src/libcalamaresui/modulesystem/CppJobModule.cpp new file mode 100644 index 0000000..c211006 --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/CppJobModule.cpp @@ -0,0 +1,118 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CppJobModule.h" + +#include "CppJob.h" +#include "utils/Logger.h" +#include "utils/PluginFactory.h" + +#include +#include + +namespace Calamares +{ + + +Module::Type +CppJobModule::type() const +{ + return Module::Type::Job; +} + + +Module::Interface +CppJobModule::interface() const +{ + return Module::Interface::QtPlugin; +} + + +void +CppJobModule::loadSelf() +{ + if ( m_loader ) + { + CalamaresPluginFactory* pf = qobject_cast< CalamaresPluginFactory* >( m_loader->instance() ); + if ( !pf ) + { + cDebug() << "Could not load module:" << m_loader->errorString(); + return; + } + + CppJob* cppJob = pf->create< Calamares::CppJob >(); + if ( !cppJob ) + { + cDebug() << "Could not load module:" << m_loader->errorString(); + return; + } + // cDebug() << "CppJobModule loading self for instance" << instanceKey() + // << "\nCppJobModule at address" << this + // << "\nCalamares::PluginFactory at address" << pf + // << "\nCppJob at address" << cppJob; + + cppJob->setModuleInstanceKey( instanceKey() ); + cppJob->setConfigurationMap( m_configurationMap ); + m_job = Calamares::job_ptr( static_cast< Calamares::Job* >( cppJob ) ); + m_loaded = true; + cDebug() << "CppJobModule" << instanceKey() << "loading complete."; + } +} + + +JobList +CppJobModule::jobs() const +{ + return JobList() << m_job; +} + + +void +CppJobModule::initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) +{ + QDir directory( location() ); + QString load = moduleDescriptor.load(); + if ( !load.isEmpty() ) + { + load = directory.absoluteFilePath( load ); + } + // If a load path is not specified, we look for a plugin to load in the directory. + if ( load.isEmpty() || !QLibrary::isLibrary( load ) ) + { + const QStringList ls = directory.entryList( QStringList { "*.so" } ); + if ( !ls.isEmpty() ) + { + for ( QString entry : ls ) + { + entry = directory.absoluteFilePath( entry ); + if ( QLibrary::isLibrary( entry ) ) + { + load = entry; + break; + } + } + } + } + + m_loader = new QPluginLoader( load ); +} + +CppJobModule::CppJobModule() + : Module() + , m_loader( nullptr ) +{ +} + +CppJobModule::~CppJobModule() +{ + delete m_loader; +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/modulesystem/CppJobModule.h b/calamares/src/libcalamaresui/modulesystem/CppJobModule.h new file mode 100644 index 0000000..b999fd0 --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/CppJobModule.h @@ -0,0 +1,50 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_CPPJOBMODULE_H +#define CALAMARES_CPPJOBMODULE_H + +#include "DllMacro.h" +#include "modulesystem/Module.h" + +class QPluginLoader; + +namespace Calamares +{ + +class UIDLLEXPORT CppJobModule : public Module +{ +public: + Type type() const override; + Interface interface() const override; + + void loadSelf() override; + JobList jobs() const override; + +protected: + void initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) override; + +private: + explicit CppJobModule(); + ~CppJobModule() override; + + QPluginLoader* m_loader; + job_ptr m_job; + + friend Module* Calamares::moduleFromDescriptor( const ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ); +}; + +} // namespace Calamares + +#endif // CALAMARES_CPPJOBMODULE_H diff --git a/calamares/src/libcalamaresui/modulesystem/ModuleFactory.cpp b/calamares/src/libcalamaresui/modulesystem/ModuleFactory.cpp new file mode 100644 index 0000000..eecc85f --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ModuleFactory.cpp @@ -0,0 +1,137 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ModuleFactory.h" + +#include "CalamaresConfig.h" +#include "CppJobModule.h" +#include "ProcessJobModule.h" +#include "ViewModule.h" + +#include "utils/Dirs.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Yaml.h" + +#ifdef WITH_PYTHON +#include "PythonJobModule.h" +#endif + +#include +#include +#include +#include + +namespace Calamares +{ + +Module* +moduleFromDescriptor( const Calamares::ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ) +{ + using Type = Calamares::ModuleSystem::Type; + using Interface = Calamares::ModuleSystem::Interface; + + std::unique_ptr< Module > m; + + if ( !moduleDescriptor.isValid() ) + { + cError() << "Bad module descriptor format" << instanceId; + return nullptr; + } + if ( moduleDescriptor.type() == Type::View ) + { + if ( moduleDescriptor.interface() == Interface::QtPlugin ) + { + m.reset( new ViewModule() ); + } + else + { + cError() << "Bad interface" + << Calamares::ModuleSystem::interfaceNames().find( moduleDescriptor.interface() ) + << "for module type" << Calamares::ModuleSystem::typeNames().find( moduleDescriptor.type() ); + } + } + else if ( moduleDescriptor.type() == Type::Job ) + { + if ( moduleDescriptor.interface() == Interface::QtPlugin ) + { + m.reset( new CppJobModule() ); + } + else if ( moduleDescriptor.interface() == Interface::Process ) + { + m.reset( new ProcessJobModule() ); + } + else if ( moduleDescriptor.interface() == Interface::Python ) + { +#ifdef WITH_PYTHON + m.reset( new PythonJobModule() ); +#else + cError() << "Python modules are not supported in this version of Calamares."; +#endif + } + else + { + cError() << "Bad interface" + << Calamares::ModuleSystem::interfaceNames().find( moduleDescriptor.interface() ) + << "for module type" << Calamares::ModuleSystem::typeNames().find( moduleDescriptor.type() ); + } + } + else + { + cError() << "Bad module type" << Calamares::ModuleSystem::typeNames().find( moduleDescriptor.type() ); + } + + if ( !m ) + { + cError() << "Bad module type (" << Calamares::ModuleSystem::typeNames().find( moduleDescriptor.type() ) + << ") or interface string (" + << Calamares::ModuleSystem::interfaceNames().find( moduleDescriptor.interface() ) << ") for module " + << instanceId; + return nullptr; + } + + QDir moduleDir( moduleDirectory ); + if ( moduleDir.exists() && moduleDir.isReadable() ) + { + m->m_directory = moduleDir.absolutePath(); + } + else + { + cError() << "Bad module directory" << moduleDirectory << "for" << instanceId; + return nullptr; + } + + m->initFrom( moduleDescriptor, instanceId ); + if ( !m->m_key.isValid() ) + { + cError() << "Module" << instanceId << "invalid ID"; + return nullptr; + } + + m->initFrom( moduleDescriptor ); + if ( !configFileName.isEmpty() ) + { + try + { + m->loadConfigurationFile( configFileName ); + } + catch ( ::YAML::Exception& e ) + { + cError() << "YAML parser error " << e.what(); + return nullptr; + } + } + return m.release(); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/modulesystem/ModuleFactory.h b/calamares/src/libcalamaresui/modulesystem/ModuleFactory.h new file mode 100644 index 0000000..170e27e --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ModuleFactory.h @@ -0,0 +1,38 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_MODULEFACTORY_H +#define CALAMARES_MODULEFACTORY_H + +#include "DllMacro.h" + +#include "modulesystem/Descriptor.h" +#include "modulesystem/Module.h" + +#include + +namespace Calamares +{ + +/** + * @brief fromDescriptor creates a new Module object of the correct type. + * @param moduleDescriptor a module descriptor, already parsed into a variant map. + * @param instanceId the instance id of the new module instance. + * @param configFileName the name of the configuration file to read. + * @param moduleDirectory the path to the directory with this module's files. + * @return a pointer to an object of a subtype of Module. + */ +UIDLLEXPORT Module* moduleFromDescriptor( const ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ); +} // namespace Calamares + +#endif // CALAMARES_MODULEFACTORY_H diff --git a/calamares/src/libcalamaresui/modulesystem/ModuleManager.cpp b/calamares/src/libcalamaresui/modulesystem/ModuleManager.cpp new file mode 100644 index 0000000..6f678a2 --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ModuleManager.cpp @@ -0,0 +1,440 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ModuleManager.h" + +#include "ViewManager.h" + +#include "Settings.h" +#include "modulesystem/Module.h" +#include "modulesystem/RequirementsChecker.h" +#include "modulesystem/RequirementsModel.h" +#include "utils/Logger.h" +#include "utils/Yaml.h" +#include "viewpages/ExecutionViewStep.h" + +#include +#include +#include + +namespace Calamares +{ +ModuleManager* ModuleManager::s_instance = nullptr; + +ModuleManager* +ModuleManager::instance() +{ + return s_instance; +} + +ModuleManager::ModuleManager( const QStringList& paths, QObject* parent ) + : QObject( parent ) + , m_paths( paths ) + , m_requirementsModel( new RequirementsModel( this ) ) +{ + Q_ASSERT( !s_instance ); + s_instance = this; +} + +ModuleManager::~ModuleManager() +{ + // The map is populated with Module::fromDescriptor(), which allocates on the heap. + for ( auto moduleptr : m_loadedModulesByInstanceKey ) + { + delete moduleptr; + } +} + +void +ModuleManager::init() +{ + QTimer::singleShot( 0, this, &ModuleManager::doInit ); +} + +void +ModuleManager::doInit() +{ + // We start from a list of paths in m_paths. Each of those is a directory that + // might (should) contain Calamares modules of any type/interface. + // For each modules search path (directory), it is expected that each module + // lives in its own subdirectory. This subdirectory must have the same name as + // the module name, and must contain a settings file named module.desc. + // If at any time the module loading procedure finds something unexpected, it + // silently skips to the next module or search path. --Teo 6/2014 + Logger::Once deb; + for ( const QString& path : m_paths ) + { + QDir currentDir( path ); + if ( currentDir.exists() && currentDir.isReadable() ) + { + const QStringList subdirs = currentDir.entryList( QDir::AllDirs | QDir::NoDotAndDotDot ); + for ( const QString& subdir : subdirs ) + { + currentDir.setPath( path ); + bool success = currentDir.cd( subdir ); + if ( success ) + { + static const char bad_descriptor[] = "ModuleManager potential module descriptor is bad"; + QFileInfo descriptorFileInfo( currentDir.absoluteFilePath( QLatin1String( "module.desc" ) ) ); + if ( !descriptorFileInfo.exists() ) + { + cDebug() << deb << bad_descriptor << descriptorFileInfo.absoluteFilePath() << "(missing)"; + continue; + } + if ( !descriptorFileInfo.isReadable() ) + { + cDebug() << deb << bad_descriptor << descriptorFileInfo.absoluteFilePath() << "(unreadable)"; + continue; + } + + bool ok = false; + QVariantMap moduleDescriptorMap = Calamares::YAML::load( descriptorFileInfo, &ok ); + QString moduleName = ok ? moduleDescriptorMap.value( "name" ).toString() : QString(); + + if ( ok && !moduleName.isEmpty() && ( moduleName == currentDir.dirName() ) + && !m_availableDescriptorsByModuleName.contains( moduleName ) ) + { + auto descriptor = Calamares::ModuleSystem::Descriptor::fromDescriptorData( + moduleDescriptorMap, descriptorFileInfo.absoluteFilePath() ); + descriptor.setDirectory( descriptorFileInfo.absoluteDir().absolutePath() ); + m_availableDescriptorsByModuleName.insert( moduleName, descriptor ); + } + else + { + // Duplicate modules are ok; other issues like empty name or dir-mismatch are reported. + if ( !m_availableDescriptorsByModuleName.contains( moduleName ) ) + { + cWarning() << deb << "ModuleManager module descriptor" + << descriptorFileInfo.absoluteFilePath() << "has bad name" << moduleName; + } + } + } + else + { + cWarning() << "ModuleManager module directory is not accessible:" << path << "/" << subdir; + } + } + } + else + { + cDebug() << deb << "ModuleManager module search path does not exist:" << path; + } + } + // At this point m_availableDescriptorsByModuleName is filled with + // the modules that were found in the search paths. + cDebug() << deb << "Found" << m_availableDescriptorsByModuleName.count() << "modules"; + QTimer::singleShot( 10, this, &ModuleManager::initDone ); +} + +QList< ModuleSystem::InstanceKey > +ModuleManager::loadedInstanceKeys() +{ + return m_loadedModulesByInstanceKey.keys(); +} + +Calamares::ModuleSystem::Descriptor +ModuleManager::moduleDescriptor( const QString& name ) +{ + return m_availableDescriptorsByModuleName.value( name ); +} + +Module* +ModuleManager::moduleInstance( const ModuleSystem::InstanceKey& instanceKey ) +{ + return m_loadedModulesByInstanceKey.value( instanceKey ); +} + +/** @brief Returns the config file name for the given @p instanceKey + * + * Custom instances have custom config files, non-custom ones + * have a .conf file. Returns an empty QString on + * errors. + */ +static QString +getConfigFileName( const Settings::InstanceDescriptionList& descriptorList, + const ModuleSystem::InstanceKey& instanceKey, + const ModuleSystem::Descriptor& thisModule ) +{ + if ( !thisModule.hasConfig() ) + { + // Explicitly set to no-configuration. This doesn't apply + // to custom instances (above) since the only reason to + // **have** a custom instance is to specify a different + // config file for more than one module. + return QString(); + } + + for ( const auto& descriptor : descriptorList ) + { + if ( descriptor.key() == instanceKey ) + { + return descriptor.configFileName(); + } + } + + // This should already have been checked and failed the module already + return QString(); +} + +void +ModuleManager::loadModules() +{ + if ( checkDependencies() ) + { + cWarning() << "Some installed modules have unmet dependencies."; + } + Settings::InstanceDescriptionList customInstances = Settings::instance()->moduleInstances(); + + QStringList failedModules; + const auto modulesSequence = Settings::instance()->modulesSequence(); + for ( const auto& modulePhase : modulesSequence ) + { + ModuleSystem::Action currentAction = modulePhase.first; + + for ( const auto& instanceKey : modulePhase.second ) + { + if ( !instanceKey.isValid() ) + { + cError() << "Wrong module entry format for module" << instanceKey; + failedModules.append( instanceKey.toString() ); + continue; + } + + const ModuleSystem::Descriptor descriptor + = m_availableDescriptorsByModuleName.value( instanceKey.module(), ModuleSystem::Descriptor() ); + if ( !descriptor.isValid() ) + { + cError() << "Module" << instanceKey.toString() << "not found in module search paths." + << Logger::DebugList( m_paths ); + failedModules.append( instanceKey.toString() ); + continue; + } + + QString configFileName = getConfigFileName( customInstances, instanceKey, descriptor ); + + // So now we can assume that the module entry is at least valid, + // that we have a descriptor on hand (and therefore that the + // module exists), and that the instance is either default or + // defined in the custom instances section. + // We still don't know whether the config file for the entry + // exists and is valid, but that's the only thing that could fail + // from this point on. -- Teo 8/2015 + Module* thisModule = m_loadedModulesByInstanceKey.value( instanceKey, nullptr ); + if ( thisModule ) + { + if ( thisModule->isLoaded() ) + { + // It's been listed before, don't bother loading again. + // This can happen for a module listed twice (e.g. with custom instances) + cDebug() << "Module" << instanceKey.toString() << "already loaded."; + } + else + { + // An attempt was made, earlier, and that failed. + // This can happen for a module listed twice (e.g. with custom instances) + cError() << "Module" << instanceKey.toString() << "exists but not loaded."; + failedModules.append( instanceKey.toString() ); + continue; + } + } + else + { + thisModule = Calamares::moduleFromDescriptor( + descriptor, instanceKey.id(), configFileName, descriptor.directory() ); + if ( !thisModule ) + { + cError() << "Module" << instanceKey.toString() << "cannot be created from descriptor" + << configFileName; + failedModules.append( instanceKey.toString() ); + continue; + } + + if ( !addModule( thisModule ) ) + { + // Error message is already printed + failedModules.append( instanceKey.toString() ); + continue; + } + } + + // At this point we most certainly have a pointer to a loaded module in + // thisModule. We now need to enqueue jobs info into an EVS. + if ( currentAction == ModuleSystem::Action::Exec ) + { + const auto steps = Calamares::ViewManager::instance()->viewSteps(); + ExecutionViewStep* evs = steps.isEmpty() ? nullptr : qobject_cast< ExecutionViewStep* >( steps.last() ); + if ( !evs ) // If the last step is not an EVS, we must create it. + { + evs = new ExecutionViewStep( ViewManager::instance() ); + ViewManager::instance()->addViewStep( evs ); + } + + evs->appendJobModuleInstanceKey( instanceKey ); + } + } + } + if ( !failedModules.isEmpty() ) + { + ViewManager::instance()->onInitFailed( failedModules ); + QTimer::singleShot( 10, [ = ]() { emit modulesFailed( failedModules ); } ); + } + else + { + QTimer::singleShot( 10, this, &ModuleManager::modulesLoaded ); + } +} + +bool +ModuleManager::addModule( Module* module ) +{ + if ( !module ) + { + return false; + } + if ( !module->instanceKey().isValid() ) + { + cWarning() << "Module" << module->location() << Logger::Pointer( module ) << "has invalid instance key."; + return false; + } + if ( !checkModuleDependencies( *module ) ) + { + return false; + } + + if ( !module->isLoaded() ) + { + module->loadSelf(); + } + + // Even if the load failed, we keep the module, so that if it tried to + // get loaded **again**, we already know. + m_loadedModulesByInstanceKey.insert( module->instanceKey(), module ); + if ( !module->isLoaded() ) + { + cError() << "Module" << module->instanceKey().toString() << "loading FAILED."; + return false; + } + + return true; +} + +void +ModuleManager::checkRequirements() +{ + cDebug() << "Checking module requirements .."; + + QVector< Module* > modules( m_loadedModulesByInstanceKey.count() ); + int count = 0; + for ( const auto& module : m_loadedModulesByInstanceKey ) + { + modules[ count++ ] = module; + } + + RequirementsChecker* rq = new RequirementsChecker( modules, m_requirementsModel, this ); + connect( rq, &RequirementsChecker::done, rq, &RequirementsChecker::deleteLater ); + connect( rq, + &RequirementsChecker::done, + this, + [ = ]() + { + if ( m_requirementsModel->satisfiedMandatory() ) + { + /* we're done */ this->requirementsComplete( true ); + } + else + { + this->requirementsComplete( false ); + QTimer::singleShot( std::chrono::seconds( 5 ), this, &ModuleManager::checkRequirements ); + } + } ); + + QTimer::singleShot( 0, rq, &RequirementsChecker::run ); +} + +static QStringList +missingRequiredModules( const QStringList& required, const QMap< QString, ModuleSystem::Descriptor >& available ) +{ + QStringList l; + for ( const QString& depName : required ) + { + if ( !available.contains( depName ) ) + { + l.append( depName ); + } + } + + return l; +} + +size_t +ModuleManager::checkDependencies() +{ + size_t numberRemoved = 0; + bool somethingWasRemovedBecauseOfUnmetDependencies = false; + + // This goes through the map of available modules, and deletes those whose + // dependencies are not met, if any. + do + { + somethingWasRemovedBecauseOfUnmetDependencies = false; + for ( auto it = m_availableDescriptorsByModuleName.begin(); it != m_availableDescriptorsByModuleName.end(); + ++it ) + { + QStringList unmet = missingRequiredModules( it->requiredModules(), m_availableDescriptorsByModuleName ); + + if ( unmet.count() > 0 ) + { + QString moduleName = it->name(); + somethingWasRemovedBecauseOfUnmetDependencies = true; + m_availableDescriptorsByModuleName.erase( it ); + numberRemoved++; + cWarning() << "Module" << moduleName << "requires missing modules" << Logger::DebugList( unmet ); + break; + } + } + } while ( somethingWasRemovedBecauseOfUnmetDependencies ); + + return numberRemoved; +} + +bool +ModuleManager::checkModuleDependencies( const Module& m ) +{ + if ( !m_availableDescriptorsByModuleName.contains( m.name() ) ) + { + cWarning() << "Module" << m.name() << "loaded externally, no dependency information."; + return true; + } + + bool allRequirementsFound = true; + QStringList requiredModules = m_availableDescriptorsByModuleName[ m.name() ].requiredModules(); + + for ( const QString& required : requiredModules ) + { + bool requirementFound = false; + for ( const Module* v : m_loadedModulesByInstanceKey ) + { + if ( required == v->name() ) + { + requirementFound = true; + break; + } + } + if ( !requirementFound ) + { + cError() << "Module" << m.name() << "requires" << required << "before it in sequence."; + allRequirementsFound = false; + } + } + + return allRequirementsFound; +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/modulesystem/ModuleManager.h b/calamares/src/libcalamaresui/modulesystem/ModuleManager.h new file mode 100644 index 0000000..015f66e --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ModuleManager.h @@ -0,0 +1,177 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef MODULELOADER_H +#define MODULELOADER_H + +#include "DllMacro.h" + +#include "modulesystem/Descriptor.h" +#include "modulesystem/InstanceKey.h" +#include "modulesystem/Requirement.h" + +#include +#include +#include + +namespace Calamares +{ + +class Module; +class RequirementsModel; + +/** + * @brief The ModuleManager class is a singleton which manages Calamares modules. + * + * It goes through the module search directories and reads module metadata. It then + * constructs objects of type Module, loads them and makes them accessible by their + * instance key. + */ +class UIDLLEXPORT ModuleManager : public QObject +{ + Q_OBJECT +public: + explicit ModuleManager( const QStringList& paths, QObject* parent = nullptr ); + ~ModuleManager() override; + + static ModuleManager* instance(); + + /** + * @brief init goes through the module search directories and gets a list of + * modules available for loading, along with their metadata. + * This information is stored as a map of Module* objects, indexed by name. + */ + void init(); + + /** + * @brief loadedInstanceKeys returns a list of instance keys for the available + * modules. + * @return a QStringList with the instance keys. + */ + QList< ModuleSystem::InstanceKey > loadedInstanceKeys(); + + /** + * @brief moduleDescriptor returns the module descriptor structure for a given module. + * @param name the name of the module for which to return the module descriptor. + * @return the module descriptor, as a variant map already parsed from YAML. + */ + ModuleSystem::Descriptor moduleDescriptor( const QString& name ); + + /** @brief returns the module descriptor structure for the module @p instance + * + * Descriptors are for the module, which may have multiple instances; + * this is the same as moduleDescriptor( instance.module() ). + */ + ModuleSystem::Descriptor moduleDescriptor( const ModuleSystem::InstanceKey& instanceKey ) + { + return moduleDescriptor( instanceKey.module() ); + } + + /** + * @brief moduleInstance returns a Module object for a given instance key. + * @param instanceKey the instance key for a module instance. + * @return a pointer to an object of a subtype of Module. + */ + Module* moduleInstance( const ModuleSystem::InstanceKey& instanceKey ); + + /** + * @brief loadModules does all of the module loading operation. + * When this is done, the signal modulesLoaded is emitted. + * It is recommended to call this from a single-shot QTimer. + */ + void loadModules(); + + /** + * @brief Adds a single module (loaded by some other means) + * + * Returns @c true on success (that is, the module's dependencies + * are satisfied, it wasn't already loaded, ...). + */ + bool addModule( Module* ); + + /** + * @brief Starts asynchronous requirements checking for each module. + * When this is done, the signal requirementsComplete is emitted. + */ + void checkRequirements(); + + ///@brief Gets the model that requirements-checking works on. + RequirementsModel* requirementsModel() { return m_requirementsModel; } + +signals: + /** @brief Emitted when all the module **configuration** has been read + * + * This indicates that all of the module.desc files have been + * loaded; bad ones are silently skipped, so this just indicates + * that the module manager is ready for the next phase (loading). + */ + void initDone(); + /** @brief Emitted when all the modules are loaded successfully + * + * Each module listed in the settings is loaded. Modules are loaded + * only once, even when instantiated multiple times. If all of + * the listed modules are successfully loaded, this signal is + * emitted (otherwise, it isn't, so you need to wait for **both** + * of the signals). + * + * If this is emitted (i.e. all modules have loaded) then the next + * phase, requirements checking, can be started. + */ + void modulesLoaded(); + /** @brief Emitted if any modules failed to load + * + * Modules that failed to load (for any reason) are listed by + * instance key (e.g. "welcome@welcome", "shellprocess@mycustomthing"). + */ + void modulesFailed( QStringList ); + /** @brief Emitted after all requirements have been checked + * + * The bool @p canContinue indicates if all of the **mandatory** requirements + * are satisfied (e.g. whether installation can continue). + */ + void requirementsComplete( bool canContinue ); + +private slots: + void doInit(); + +private: + /** + * Check in a general sense whether the dependencies between + * modules are valid. Returns the number of modules that + * have missing dependencies -- this is **not** a problem + * unless any of those modules are actually used. + * + * Returns 0 on success. + * + * Also modifies m_availableDescriptorsByModuleName to remove + * all the entries that (so that later, when we try to look + * them up, they are not found). + */ + size_t checkDependencies(); + + /** + * Check for this specific module if its required modules have + * already been loaded (i.e. are in sequence before it). + * + * Returns true if the requirements are met. + */ + bool checkModuleDependencies( const Module& ); + + QMap< QString, ModuleSystem::Descriptor > m_availableDescriptorsByModuleName; + QMap< ModuleSystem::InstanceKey, Module* > m_loadedModulesByInstanceKey; + const QStringList m_paths; + RequirementsModel* m_requirementsModel; + + static ModuleManager* s_instance; +}; + +} // namespace Calamares + +#endif // MODULELOADER_H diff --git a/calamares/src/libcalamaresui/modulesystem/ProcessJobModule.cpp b/calamares/src/libcalamaresui/modulesystem/ProcessJobModule.cpp new file mode 100644 index 0000000..2671d08 --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ProcessJobModule.cpp @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ProcessJobModule.h" + +#include "ProcessJob.h" + +#include + +namespace Calamares +{ + + +Module::Type +ProcessJobModule::type() const +{ + return Module::Type::Job; +} + + +Module::Interface +ProcessJobModule::interface() const +{ + return Module::Interface::Process; +} + + +void +ProcessJobModule::loadSelf() +{ + if ( m_loaded ) + { + return; + } + + m_job = job_ptr( new ProcessJob( m_command, m_workingPath, m_runInChroot, m_secondsTimeout ) ); + m_loaded = true; +} + + +JobList +ProcessJobModule::jobs() const +{ + return JobList() << m_job; +} + + +void +ProcessJobModule::initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) +{ + QDir directory( location() ); + m_workingPath = directory.absolutePath(); + + m_command = moduleDescriptor.command(); + m_secondsTimeout = std::chrono::seconds( moduleDescriptor.timeout() ); + m_runInChroot = moduleDescriptor.chroot(); +} + + +ProcessJobModule::ProcessJobModule() + : Module() + , m_secondsTimeout( std::chrono::seconds( 30 ) ) + , m_runInChroot( false ) +{ +} + + +ProcessJobModule::~ProcessJobModule() {} + + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/modulesystem/ProcessJobModule.h b/calamares/src/libcalamaresui/modulesystem/ProcessJobModule.h new file mode 100644 index 0000000..645127d --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ProcessJobModule.h @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PROCESSJOBMODULE_H +#define CALAMARES_PROCESSJOBMODULE_H + +#include "DllMacro.h" +#include "modulesystem/Module.h" + +#include + +namespace Calamares +{ + +class UIDLLEXPORT ProcessJobModule : public Module +{ +public: + Type type() const override; + Interface interface() const override; + + void loadSelf() override; + JobList jobs() const override; + +protected: + void initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) override; + +private: + explicit ProcessJobModule(); + ~ProcessJobModule() override; + + QString m_command; + QString m_workingPath; + std::chrono::seconds m_secondsTimeout; + bool m_runInChroot; + job_ptr m_job; + + friend Module* Calamares::moduleFromDescriptor( const ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ); +}; + +} // namespace Calamares + +#endif // CALAMARES_PROCESSJOBMODULE_H diff --git a/calamares/src/libcalamaresui/modulesystem/PythonJobModule.cpp b/calamares/src/libcalamaresui/modulesystem/PythonJobModule.cpp new file mode 100644 index 0000000..95109f1 --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/PythonJobModule.cpp @@ -0,0 +1,84 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PythonJobModule.h" + +#include "CalamaresConfig.h" +#ifdef WITH_PYBIND11 +#include "pybind11/PythonJob.h" +using JobType = Calamares::Python::Job; +#elif defined( WITH_BOOST_PYTHON ) +// Old Boost::Python version +#include "pyboost/PythonJob.h" +using JobType = Calamares::PythonJob; +#else +#error Python without bindings +#endif + +#include + + +namespace Calamares +{ + + +Module::Type +PythonJobModule::type() const +{ + return Module::Type::Job; +} + + +Module::Interface +PythonJobModule::interface() const +{ + return Module::Interface::Python; +} + + +void +PythonJobModule::loadSelf() +{ + if ( m_loaded ) + { + return; + } + + m_job = Calamares::job_ptr( new JobType( m_scriptFileName, m_workingPath, m_configurationMap ) ); + m_loaded = true; +} + + +JobList +PythonJobModule::jobs() const +{ + return JobList() << m_job; +} + + +void +PythonJobModule::initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) +{ + QDir directory( location() ); + m_workingPath = directory.absolutePath(); + m_scriptFileName = moduleDescriptor.script(); +} + + +PythonJobModule::PythonJobModule() + : Module() +{ +} + + +PythonJobModule::~PythonJobModule() {} + + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/modulesystem/PythonJobModule.h b/calamares/src/libcalamaresui/modulesystem/PythonJobModule.h new file mode 100644 index 0000000..4424cc7 --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/PythonJobModule.h @@ -0,0 +1,47 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_PYTHONJOBMODULE_H +#define CALAMARES_PYTHONJOBMODULE_H + +#include "DllMacro.h" +#include "modulesystem/Module.h" + +namespace Calamares +{ + +class UIDLLEXPORT PythonJobModule : public Module +{ +public: + Type type() const override; + Interface interface() const override; + + void loadSelf() override; + JobList jobs() const override; + +protected: + void initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) override; + +private: + explicit PythonJobModule(); + ~PythonJobModule() override; + + QString m_scriptFileName; + QString m_workingPath; + job_ptr m_job; + + friend Module* Calamares::moduleFromDescriptor( const ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ); +}; + +} // namespace Calamares + +#endif // CALAMARES_PYTHONJOBMODULE_H diff --git a/calamares/src/libcalamaresui/modulesystem/ViewModule.cpp b/calamares/src/libcalamaresui/modulesystem/ViewModule.cpp new file mode 100644 index 0000000..32c05bd --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ViewModule.cpp @@ -0,0 +1,129 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ViewModule.h" + +#include "ViewManager.h" +#include "utils/Logger.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include +#include + +namespace Calamares +{ + + +Module::Type +ViewModule::type() const +{ + return Module::Type::View; +} + + +Module::Interface +ViewModule::interface() const +{ + return Module::Interface::QtPlugin; +} + + +void +ViewModule::loadSelf() +{ + if ( m_loader ) + { + CalamaresPluginFactory* pf = qobject_cast< CalamaresPluginFactory* >( m_loader->instance() ); + if ( !pf ) + { + cWarning() << "No factory:" << m_loader->errorString(); + return; + } + + m_viewStep = pf->create< Calamares::ViewStep >(); + if ( !m_viewStep ) + { + cWarning() << "create() failed" << m_loader->errorString(); + return; + } + } + + // If any method created the view step, use it now. + if ( m_viewStep ) + { + m_viewStep->setModuleInstanceKey( instanceKey() ); + m_viewStep->setConfigurationMap( m_configurationMap ); + ViewManager::instance()->addViewStep( m_viewStep ); + m_loaded = true; + cDebug() << "ViewModule" << instanceKey() << "loading complete."; + } + else + { + cWarning() << "No view step was created"; + } +} + + +JobList +ViewModule::jobs() const +{ + return m_viewStep->jobs(); +} + + +void +ViewModule::initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) +{ + QDir directory( location() ); + QString load = moduleDescriptor.load(); + if ( !load.isEmpty() ) + { + load = directory.absoluteFilePath( load ); + } + // If a load path is not specified, we look for a plugin to load in the directory. + if ( load.isEmpty() || !QLibrary::isLibrary( load ) ) + { + const QStringList ls = directory.entryList( QStringList { "*.so" } ); + if ( !ls.isEmpty() ) + { + for ( QString entry : ls ) + { + entry = directory.absoluteFilePath( entry ); + if ( QLibrary::isLibrary( entry ) ) + { + load = entry; + break; + } + } + } + } + + m_loader = new QPluginLoader( load ); +} + +ViewModule::ViewModule() + : Module() + , m_loader( nullptr ) +{ +} + +ViewModule::~ViewModule() +{ + delete m_loader; +} + +RequirementsList +ViewModule::checkRequirements() +{ + return m_viewStep->checkRequirements(); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/modulesystem/ViewModule.h b/calamares/src/libcalamaresui/modulesystem/ViewModule.h new file mode 100644 index 0000000..217611b --- /dev/null +++ b/calamares/src/libcalamaresui/modulesystem/ViewModule.h @@ -0,0 +1,53 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARES_VIEWMODULE_H +#define CALAMARES_VIEWMODULE_H + +#include "DllMacro.h" +#include "modulesystem/Module.h" + +class QPluginLoader; + +namespace Calamares +{ + +class ViewStep; + +class UIDLLEXPORT ViewModule : public Module +{ +public: + Type type() const override; + Interface interface() const override; + + void loadSelf() override; + JobList jobs() const override; + + RequirementsList checkRequirements() override; + +protected: + void initFrom( const ModuleSystem::Descriptor& moduleDescriptor ) override; + +private: + explicit ViewModule(); + ~ViewModule() override; + + QPluginLoader* m_loader; + ViewStep* m_viewStep = nullptr; + + friend Module* Calamares::moduleFromDescriptor( const ModuleSystem::Descriptor& moduleDescriptor, + const QString& instanceId, + const QString& configFileName, + const QString& moduleDirectory ); +}; + +} // namespace Calamares + +#endif // CALAMARES_VIEWMODULE_H diff --git a/calamares/src/libcalamaresui/utils/Gui.cpp b/calamares/src/libcalamaresui/utils/Gui.cpp new file mode 100644 index 0000000..57f67ae --- /dev/null +++ b/calamares/src/libcalamaresui/utils/Gui.cpp @@ -0,0 +1,194 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Gui.h" + +#include "ImageRegistry.h" + +#include +#include +#include +#include +#include +#include +#include + +#define RESPATH ":/data/" + +namespace Calamares +{ + +static int s_defaultFontSize = 0; +static int s_defaultFontHeight = 0; + +QPixmap +defaultPixmap( ImageType type, ImageMode mode, const QSize& size ) +{ + Q_UNUSED( mode ) + QPixmap pixmap; + + switch ( type ) + { + case Yes: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/yes.svgz", size ); + break; + + case No: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/no.svgz", size ); + break; + + case Information: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/information.svgz", size ); + break; + + case Fail: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/fail.svgz", size ); + break; + + case Bugs: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/bugs.svg", size ); + break; + + case Help: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/help.svg", size ); + break; + + case Release: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/release.svg", size ); + break; + + case Donate: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/donate.svg", size ); + break; + + case PartitionDisk: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/partition-disk.svg", size ); + break; + + case PartitionPartition: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/partition-partition.svg", size ); + break; + + case PartitionAlongside: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/partition-alongside.svg", size ); + break; + + case PartitionEraseAuto: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/partition-erase-auto.svg", size ); + break; + + case PartitionManual: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/partition-manual.svg", size ); + break; + + case PartitionReplaceOs: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/partition-replace-os.svg", size ); + break; + + case PartitionTable: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/partition-table.svg", size ); + break; + + case BootEnvironment: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/boot-environment.svg", size ); + break; + + case Squid: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/squid.svg", size ); + break; + + case StatusOk: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/state-ok.svg", size ); + break; + + case StatusWarning: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/state-warning.svg", size ); + break; + + case StatusError: + pixmap = ImageRegistry::instance()->pixmap( RESPATH "images/state-error.svg", size ); + break; + } + + if ( pixmap.isNull() ) + { + Q_ASSERT( false ); + return QPixmap(); + } + + return pixmap; +} + +void +unmarginLayout( QLayout* layout ) +{ + if ( layout ) + { + layout->setContentsMargins( 0, 0, 0, 0 ); + layout->setSpacing( 0 ); + + for ( int i = 0; i < layout->count(); i++ ) + { + auto* childItem = layout->itemAt( i ); + QLayout* childLayout = childItem ? childItem->layout() : nullptr; + if ( childLayout ) + { + unmarginLayout( childLayout ); + } + } + } +} + +int +defaultFontSize() +{ + if ( s_defaultFontSize <= 0 ) + { + s_defaultFontSize = QFont().pointSize(); + } + return s_defaultFontSize; +} + +int +defaultFontHeight() +{ + if ( s_defaultFontHeight <= 0 ) + { + QFont f; + f.setPointSize( defaultFontSize() ); + s_defaultFontHeight = QFontMetrics( f ).height(); + } + + return s_defaultFontHeight; +} + +QFont +largeFont() +{ + QFont f; + f.setPointSize( defaultFontSize() + 4 ); + return f; +} + +void +setDefaultFontSize( int points ) +{ + s_defaultFontSize = points; + s_defaultFontHeight = 0; // Recalculate on next call to defaultFontHeight() +} + +QSize +defaultIconSize() +{ + const int w = int( defaultFontHeight() * 1.6 ); + return QSize( w, w ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/utils/Gui.h b/calamares/src/libcalamaresui/utils/Gui.h new file mode 100644 index 0000000..1264bc1 --- /dev/null +++ b/calamares/src/libcalamaresui/utils/Gui.h @@ -0,0 +1,102 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CALAMARESUTILSGUI_H +#define CALAMARESUTILSGUI_H + +#include "DllMacro.h" + +#include +#include +#include + +class QLayout; + +namespace Calamares +{ + +/** + * @brief The ImageType enum lists all common Calamares icons. + * Icons are loaded from SVGs and cached. Each icon has an enum value, through which + * it can be accessed. + * You can forward-declare this as: + * enum ImageType : int; + */ +enum ImageType : int +{ + Yes, + No, + Information, + Fail, + Bugs, + Help, + Release, + Donate, + PartitionDisk, + PartitionPartition, + PartitionAlongside, + PartitionEraseAuto, + PartitionManual, + PartitionReplaceOs, + PartitionTable, + BootEnvironment, + Squid, + StatusOk, // Icons for the requirements checker + StatusWarning, + StatusError +}; + +/** + * @brief The ImageMode enum contains different transformations that can be applied. + * Most of these are currently unused. + */ +enum ImageMode +{ + Original, + CoverInCase, + Grid, + DropShadow, +}; + +/** + * @brief defaultPixmap returns a resized and/or transformed pixmap for a given + * ImageType. + * @param type the ImageType i.e. the enum value for an SVG. + * @param mode the transformation to apply (default: no transformation). + * @param size the target pixmap size (default: original SVG size). + * @return the new pixmap. + */ +UIDLLEXPORT QPixmap defaultPixmap( ImageType type, + ImageMode mode = Calamares::Original, + const QSize& size = QSize( 0, 0 ) ); + +/** + * @brief unmarginLayout recursively walks the QLayout tree and removes all margins. + * @param layout the layout to unmargin. + */ +UIDLLEXPORT void unmarginLayout( QLayout* layout ); + +UIDLLEXPORT void setDefaultFontSize( int points ); +UIDLLEXPORT int defaultFontSize(); // in points +UIDLLEXPORT int defaultFontHeight(); // in pixels, DPI-specific +UIDLLEXPORT QFont largeFont(); +UIDLLEXPORT QSize defaultIconSize(); + +/** + * @brief Size constants for the main Calamares window. + */ +constexpr int windowMinimumWidth = 800; +constexpr int windowMinimumHeight = 520; +constexpr int windowPreferredWidth = 1024; +constexpr int windowPreferredHeight = 520; + +} // namespace Calamares + +#endif // CALAMARESUTILSGUI_H diff --git a/calamares/src/libcalamaresui/utils/ImageRegistry.cpp b/calamares/src/libcalamaresui/utils/ImageRegistry.cpp new file mode 100644 index 0000000..46fda0c --- /dev/null +++ b/calamares/src/libcalamaresui/utils/ImageRegistry.cpp @@ -0,0 +1,128 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2012 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2019, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "ImageRegistry.h" + +#include +#include +#include +#include + +static QHash< QString, QHash< int, QHash< qint64, QPixmap > > > s_cache; + +ImageRegistry* +ImageRegistry::instance() +{ + static ImageRegistry* s_instance = new ImageRegistry(); + return s_instance; +} + +ImageRegistry::ImageRegistry() {} + +QIcon +ImageRegistry::icon( const QString& image, Calamares::ImageMode mode ) +{ + return pixmap( image, Calamares::defaultIconSize(), mode ); +} + +qint64 +ImageRegistry::cacheKey( const QSize& size ) +{ + return size.width() * 100 + size.height() * 10; +} + +QPixmap +ImageRegistry::pixmap( const QString& image, const QSize& size, Calamares::ImageMode mode ) +{ + Q_ASSERT( !( size.width() < 0 || size.height() < 0 ) ); + if ( size.width() < 0 || size.height() < 0 ) + { + return QPixmap(); + } + + QHash< qint64, QPixmap > subsubcache; + QHash< int, QHash< qint64, QPixmap > > subcache; + + if ( s_cache.contains( image ) ) + { + subcache = s_cache.value( image ); + + if ( subcache.contains( mode ) ) + { + subsubcache = subcache.value( mode ); + + const qint64 ck = cacheKey( size ); + if ( subsubcache.contains( ck ) ) + { + return subsubcache.value( ck ); + } + } + } + + // Image not found in cache. Let's load it. + QPixmap pixmap; + if ( image.toLower().endsWith( ".svg" ) ) + { + QSvgRenderer svgRenderer( image ); + QPixmap p( size.isNull() || size.height() == 0 || size.width() == 0 ? svgRenderer.defaultSize() : size ); + p.fill( Qt::transparent ); + + QPainter pixPainter( &p ); + svgRenderer.render( &pixPainter ); + pixPainter.end(); + + pixmap = p; + } + else + { + pixmap = QPixmap( image ); + } + + if ( !pixmap.isNull() ) + { + if ( !size.isNull() && pixmap.size() != size ) + { + if ( size.width() == 0 ) + { + pixmap = pixmap.scaledToHeight( size.height(), Qt::SmoothTransformation ); + } + else if ( size.height() == 0 ) + { + pixmap = pixmap.scaledToWidth( size.width(), Qt::SmoothTransformation ); + } + else + { + pixmap = pixmap.scaled( size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation ); + } + } + + putInCache( image, size, mode, pixmap ); + } + + return pixmap; +} + +void +ImageRegistry::putInCache( const QString& image, const QSize& size, Calamares::ImageMode mode, const QPixmap& pixmap ) +{ + QHash< qint64, QPixmap > subsubcache; + QHash< int, QHash< qint64, QPixmap > > subcache; + + if ( s_cache.contains( image ) ) + { + subcache = s_cache.value( image ); + if ( subcache.contains( mode ) ) + { + subsubcache = subcache.value( mode ); + } + } + + subsubcache.insert( cacheKey( size ), pixmap ); + subcache.insert( mode, subsubcache ); + s_cache.insert( image, subcache ); +} diff --git a/calamares/src/libcalamaresui/utils/ImageRegistry.h b/calamares/src/libcalamaresui/utils/ImageRegistry.h new file mode 100644 index 0000000..0ced780 --- /dev/null +++ b/calamares/src/libcalamaresui/utils/ImageRegistry.h @@ -0,0 +1,32 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2012 Christian Muehlhaeuser + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#ifndef IMAGE_REGISTRY_H +#define IMAGE_REGISTRY_H + +#include + +#include "DllMacro.h" +#include "utils/Gui.h" + +class UIDLLEXPORT ImageRegistry +{ +public: + static ImageRegistry* instance(); + + explicit ImageRegistry(); + + QIcon icon( const QString& image, Calamares::ImageMode mode = Calamares::Original ); + QPixmap pixmap( const QString& image, const QSize& size, Calamares::ImageMode mode = Calamares::Original ); + +private: + qint64 cacheKey( const QSize& size ); + void putInCache( const QString& image, const QSize& size, Calamares::ImageMode mode, const QPixmap& pixmap ); +}; + +#endif // IMAGE_REGISTRY_H diff --git a/calamares/src/libcalamaresui/utils/Paste.cpp b/calamares/src/libcalamaresui/utils/Paste.cpp new file mode 100644 index 0000000..bcabc71 --- /dev/null +++ b/calamares/src/libcalamaresui/utils/Paste.cpp @@ -0,0 +1,196 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Bill Auger + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Paste.h" + +#include "Branding.h" +#include "DllMacro.h" +#include "utils/Logger.h" +#include "utils/Units.h" +#include "widgets/TranslationFix.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Calamares::Units; + +/** @brief Reads the logfile, returns its contents. + * + * Returns an empty QByteArray() on any kind of error. + */ +STATICTEST QByteArray +logFileContents( const qint64 sizeLimitBytes ) +{ + if ( sizeLimitBytes > 0 ) + { + cDebug() << "Log upload size limit was limited to" << sizeLimitBytes << "bytes"; + } + if ( sizeLimitBytes == 0 ) + { + cDebug() << "Log upload size is 0, upload disabled."; + return QByteArray(); + } + + const QString name = Logger::logFile(); + QFile pasteSourceFile( name ); + if ( !pasteSourceFile.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + cWarning() << "Could not open log file" << name; + return QByteArray(); + } + if ( sizeLimitBytes < 0 ) + { + return pasteSourceFile.readAll(); + } + QFileInfo fi( pasteSourceFile ); + if ( fi.size() > sizeLimitBytes ) + { + cDebug() << "Only last" << sizeLimitBytes << "bytes of log file (sized" << fi.size() << "bytes) uploaded"; + fi.refresh(); // Because we just wrote to the file with that cDebug() ^^ + pasteSourceFile.seek( fi.size() - sizeLimitBytes ); + } + return pasteSourceFile.read( sizeLimitBytes ); +} + +STATICTEST QString +ficheLogUpload( const QByteArray& pasteData, const QUrl& serverUrl, QObject* parent ) +{ + QTcpSocket* socket = new QTcpSocket( parent ); + // 16 bits of port-number + socket->connectToHost( serverUrl.host(), quint16( serverUrl.port() ) ); + + if ( !socket->waitForConnected() ) + { + cError() << "Could not connect to paste server"; + socket->close(); + return QString(); + } + + cDebug() << "Connected to paste server" << serverUrl.host(); + + socket->write( pasteData ); + + if ( !socket->waitForBytesWritten() ) + { + cError() << "Could not write to paste server"; + socket->close(); + return QString(); + } + + cDebug() << Logger::SubEntry << "Paste data written to paste server"; + + if ( !socket->waitForReadyRead() ) + { + cError() << "No data from paste server"; + socket->close(); + return QString(); + } + + cDebug() << Logger::SubEntry << "Reading response from paste server"; + QByteArray responseText = socket->readLine( 1024 ); + socket->close(); + + QUrl pasteUrl = QUrl( QString( responseText ).trimmed(), QUrl::StrictMode ); + if ( pasteUrl.isValid() && pasteUrl.host() == serverUrl.host() ) + { + cDebug() << Logger::SubEntry << "Paste server results:" << pasteUrl; + return pasteUrl.toString(); + } + else + { + cError() << "No data from paste server"; + return QString(); + } +} + +QString +Calamares::Paste::doLogUpload( QObject* parent ) +{ + auto [ type, serverUrl, sizeLimitBytes ] = Calamares::Branding::instance()->uploadServer(); + if ( !serverUrl.isValid() ) + { + cWarning() << "Upload configured with invalid URL"; + return QString(); + } + if ( type == Calamares::Branding::UploadServerType::None ) + { + // Early return to avoid reading the log file + return QString(); + } + if ( sizeLimitBytes == 0 ) + { + // Suggests that it is un-set in the config file + cWarning() << "Upload configured to send 0 bytes"; + return QString(); + } + + QByteArray pasteData = logFileContents( sizeLimitBytes ); + if ( pasteData.isEmpty() ) + { + // An error has already been logged + return QString(); + } + + switch ( type ) + { + case Calamares::Branding::UploadServerType::None: + cWarning() << "No upload configured."; + return QString(); + case Calamares::Branding::UploadServerType::Fiche: + return ficheLogUpload( pasteData, serverUrl, parent ); + } + return QString(); +} + +QString +Calamares::Paste::doLogUploadUI( QWidget* parent ) +{ + // These strings originated in the ViewManager class + QString pasteUrl = Calamares::Paste::doLogUpload( parent ); + QString pasteUrlMessage; + if ( pasteUrl.isEmpty() ) + { + pasteUrlMessage = QCoreApplication::translate( "Calamares::ViewManager", + "The upload was unsuccessful. No web-paste was done." ); + } + else + { + QClipboard* clipboard = QApplication::clipboard(); + clipboard->setText( pasteUrl, QClipboard::Clipboard ); + + if ( clipboard->supportsSelection() ) + { + clipboard->setText( pasteUrl, QClipboard::Selection ); + } + QString pasteUrlFmt = QCoreApplication::translate( "Calamares::ViewManager", + "Install log posted to\n\n%1\n\nLink copied to clipboard" ); + pasteUrlMessage = pasteUrlFmt.arg( pasteUrl ); + } + + QMessageBox mb( QMessageBox::Critical, + QCoreApplication::translate( "Calamares::ViewManager", "Install Log Paste URL" ), + pasteUrlMessage, + QMessageBox::Ok ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); + return pasteUrl; +} + +bool +Calamares::Paste::isEnabled() +{ + auto [ type, serverUrl, sizeLimitBytes ] = Calamares::Branding::instance()->uploadServer(); + return type != Calamares::Branding::UploadServerType::None && sizeLimitBytes != 0; +} diff --git a/calamares/src/libcalamaresui/utils/Paste.h b/calamares/src/libcalamaresui/utils/Paste.h new file mode 100644 index 0000000..9cec18c --- /dev/null +++ b/calamares/src/libcalamaresui/utils/Paste.h @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Bill Auger + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UTILS_PASTE_H +#define UTILS_PASTE_H + +#include "DllMacro.h" + +#include + +class QObject; +class QWidget; + +namespace Calamares +{ +namespace Paste +{ +/** @brief Send the current log file to a pastebin + * + * Returns the (string) URL that the pastebin gives us. + */ +UIDLLEXPORT QString doLogUpload( QObject* parent ); + +/** @brief Send the current log file to a pastebin + * + * As doLogUpload(), but also sets the clipboard and displays + * a message saying it's been done. + */ +UIDLLEXPORT QString doLogUploadUI( QWidget* parent ); + +/** @brief Is paste enabled? + * + * Checks the branding instance if paste can be done. + */ +UIDLLEXPORT bool isEnabled(); +} // namespace Paste + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamaresui/utils/Qml.cpp b/calamares/src/libcalamaresui/utils/Qml.cpp new file mode 100644 index 0000000..c6e5956 --- /dev/null +++ b/calamares/src/libcalamaresui/utils/Qml.cpp @@ -0,0 +1,254 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Qml.h" + +#include "Branding.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "ViewManager.h" +#include "network/Manager.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" + +#include +#include +#include +#include +#include + +static QDir s_qmlModulesDir( QString( CMAKE_INSTALL_FULL_DATADIR ) + "/qml" ); + +namespace Calamares +{ +QDir +qmlModulesDir() +{ + return s_qmlModulesDir; +} + +void +setQmlModulesDir( const QDir& dir ) +{ + s_qmlModulesDir = dir; +} + +static QStringList +qmlDirCandidates( bool assumeBuilddir ) +{ + static const char QML[] = "qml"; + + QStringList qmlDirs; + if ( Calamares::isAppDataDirOverridden() ) + { + qmlDirs << Calamares::appDataDir().absoluteFilePath( QML ); + } + else + { + if ( assumeBuilddir ) + { + qmlDirs << QDir::current().absoluteFilePath( "src/qml" ); // In build-dir + } + if ( Calamares::haveExtraDirs() ) + { + for ( auto s : Calamares::extraDataDirs() ) + { + qmlDirs << ( s + QML ); + } + } + qmlDirs << Calamares::appDataDir().absoluteFilePath( QML ); + } + + return qmlDirs; +} + +bool +initQmlModulesDir() +{ + QStringList qmlDirCandidatesByPriority + = qmlDirCandidates( Calamares::Settings::instance() && Calamares::Settings::instance()->debugMode() ); + + for ( const QString& path : qmlDirCandidatesByPriority ) + { + QDir dir( path ); + if ( dir.exists() && dir.isReadable() ) + { + cDebug() << "Using Calamares QML directory" << dir.absolutePath(); + Calamares::setQmlModulesDir( dir ); + return true; + } + } + + cError() << "Cowardly refusing to continue startup without a QML directory." + << Logger::DebugList( qmlDirCandidatesByPriority ); + if ( Calamares::isAppDataDirOverridden() ) + { + cError() << "FATAL: explicitly configured application data directory is missing qml/"; + } + else + { + cError() << "FATAL: none of the expected QML paths exist."; + } + return false; +} + +void +callQmlFunction( QQuickItem* qmlObject, const char* method ) +{ + QByteArray methodSignature( method ); + methodSignature.append( "()" ); + + if ( qmlObject && qmlObject->metaObject()->indexOfMethod( methodSignature ) >= 0 ) + { + QVariant returnValue; + QMetaObject::invokeMethod( qmlObject, method, Q_RETURN_ARG( QVariant, returnValue ) ); + if ( !returnValue.isNull() ) + { + cDebug() << "QML" << methodSignature << "returned" << returnValue; + } + } + else if ( qmlObject ) + { + cDebug() << "QML" << methodSignature << "is missing."; + } +} + +/** @brief Appends to @p candidates suitable expansions of @p names + * + * Depending on @p method, adds search expansions for branding, or QRC, + * or both (with branding having precedence). + */ +static void +addExpansions( QmlSearch method, QStringList& candidates, const QStringList& names ) +{ + QString bPath( QStringLiteral( "%1/%2.qml" ) ); + QString qrPath( QStringLiteral( ":/%1.qml" ) ); + + if ( ( method == QmlSearch::Both ) || ( method == QmlSearch::BrandingOnly ) ) + { + QString brandDir = Calamares::Branding::instance()->componentDirectory(); + std::transform( names.constBegin(), + names.constEnd(), + std::back_inserter( candidates ), + [ & ]( const QString& s ) { return s.isEmpty() ? QString() : bPath.arg( brandDir, s ); } ); + } + if ( ( method == QmlSearch::Both ) || ( method == QmlSearch::QrcOnly ) ) + { + std::transform( names.constBegin(), + names.constEnd(), + std::back_inserter( candidates ), + [ & ]( const QString& s ) { return s.isEmpty() ? QString() : qrPath.arg( s ); } ); + } +} + +/** @brief Does actual search and returns result. + * + * Empty items in @p candidates are ignored. + */ +static QString +searchQmlFile( QmlSearch method, const QString& configuredName, const QStringList& hints ) +{ + QStringList candidates; + if ( configuredName.startsWith( '/' ) ) + { + candidates << configuredName; + } + addExpansions( method, candidates, hints ); + + for ( const QString& candidate : candidates ) + { + if ( candidate.isEmpty() ) + { + continue; + } + cDebug() << Logger::SubEntry << "Looking at QML file" << candidate; + if ( QFile::exists( candidate ) ) + { + if ( candidate.startsWith( ':' ) ) + { + // Inconsistency: QFile only sees the file with :, + // but QML needs an explicit scheme (of qrc:) + return QStringLiteral( "qrc" ) + candidate; + } + return candidate; + } + } + cDebug() << Logger::SubEntry << "None found."; + return QString(); +} + +QString +searchQmlFile( QmlSearch method, const QString& configuredName, const Calamares::ModuleSystem::InstanceKey& i ) +{ + cDebug() << "Looking for QML for" << i.toString(); + return searchQmlFile( method, configuredName, { configuredName, i.toString(), i.module() } ); +} + +QString +searchQmlFile( QmlSearch method, const QString& configuredName ) +{ + cDebug() << "Looking for QML for" << configuredName; + return searchQmlFile( method, configuredName, { configuredName } ); +} + +const NamedEnumTable< QmlSearch >& +qmlSearchNames() +{ + // *INDENT-OFF* + // clang-format off + static NamedEnumTable< QmlSearch > names { + { QStringLiteral( "both" ), QmlSearch::Both }, + { QStringLiteral( "qrc" ), QmlSearch::QrcOnly }, + { QStringLiteral( "branding" ), QmlSearch::BrandingOnly } + }; + // *INDENT-ON* + // clang-format on + + return names; +} + +void +registerQmlModels() +{ + static bool done = false; + if ( !done ) + { + done = true; + // Because branding and viewmanager have a parent (CalamaresApplication + // and CalamaresWindow), they will not be deleted by QmlEngine. + // https://doc.qt.io/qt-5/qtqml-cppintegration-data.html#data-ownership + qmlRegisterSingletonType< Calamares::Branding >( "io.calamares.ui", + 1, + 0, + "Branding", + []( QQmlEngine*, QJSEngine* ) -> QObject* + { return Calamares::Branding::instance(); } ); + qmlRegisterSingletonType< Calamares::ViewManager >( "io.calamares.ui", + 1, + 0, + "ViewManager", + []( QQmlEngine*, QJSEngine* ) -> QObject* + { return Calamares::ViewManager::instance(); } ); + qmlRegisterSingletonType< Calamares::GlobalStorage >( + "io.calamares.core", + 1, + 0, + "Global", + []( QQmlEngine*, QJSEngine* ) -> QObject* { return Calamares::JobQueue::instance()->globalStorage(); } ); + qmlRegisterSingletonType< Calamares::Network::Manager >( "io.calamares.core", + 1, + 0, + "Network", + []( QQmlEngine*, QJSEngine* ) -> QObject* + { return new Calamares::Network::Manager; } ); + } +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/utils/Qml.h b/calamares/src/libcalamaresui/utils/Qml.h new file mode 100644 index 0000000..6f44bc3 --- /dev/null +++ b/calamares/src/libcalamaresui/utils/Qml.h @@ -0,0 +1,90 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UTILS_QML_H +#define UTILS_QML_H + +#include "DllMacro.h" + +#include "modulesystem/InstanceKey.h" +#include "utils/NamedEnum.h" + +#include + +class QQuickItem; + +namespace Calamares +{ +/// @brief the extra directory where Calamares searches for QML files +UIDLLEXPORT QDir qmlModulesDir(); +/// @brief sets specific directory for searching for QML files +UIDLLEXPORT void setQmlModulesDir( const QDir& dir ); + +/** @brief initialize QML search path with branding directories + * + * Picks a suitable branding directory (from the build-dir in debug mode, + * otherwise based on the branding directory) and adds it to the + * QML modules directory; returns @c false if none is found. + */ +UIDLLEXPORT bool initQmlModulesDir(); + +/** @brief Sets up global Calamares models for QML + * + * This needs to be called at least once to make the global Calamares + * models (Branding, ViewManager, ...) available to QML. + * + * The following objects are made available globally: + * - `io.calamares.ui.Branding` (an object, see Branding.h) + * - `io.calamares.core.ViewManager` (a model, see ViewManager.h) + * - `io.calamares.core.Global` (an object, see GlobalStorage.h) + * Additionally, modules based on QmlViewStep have a context + * property `config` referring to that module's configuration (if any). + */ +UIDLLEXPORT void registerQmlModels(); + +/** @brief Calls the QML method @p method on @p qmlObject + * + * Pass in only the name of the method (e.g. onActivate). This function + * checks if the method exists (with no arguments) before trying to + * call it, so that no warnings are printed due to missing methods. + * + * If there is a return value from the QML method, it is logged (but not otherwise used). + */ +UIDLLEXPORT void callQmlFunction( QQuickItem* qmlObject, const char* method ); + +/** @brief Search modes for loading Qml files. + * + * A QML file could be compiled into QRC, or it could live + * in the branding directory (and, in debug-runs, in + * the current-directory). Modules have some control + * over where the search is done. + */ +enum class QmlSearch +{ + QrcOnly, + BrandingOnly, + Both +}; + +/// @brief Names for the search terms (in config files) +UIDLLEXPORT const NamedEnumTable< QmlSearch >& qmlSearchNames(); + +/** @brief Find a suitable QML file, given the search method and name hints + * + * Returns QString() if nothing is found (which would mean the module + * is badly configured). + */ +UIDLLEXPORT QString searchQmlFile( QmlSearch method, + const QString& configuredName, + const Calamares::ModuleSystem::InstanceKey& i ); +UIDLLEXPORT QString searchQmlFile( QmlSearch method, const QString& fileNameNoSuffix ); + +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamaresui/utils/QtCompat.h b/calamares/src/libcalamaresui/utils/QtCompat.h new file mode 100644 index 0000000..b568fbf --- /dev/null +++ b/calamares/src/libcalamaresui/utils/QtCompat.h @@ -0,0 +1,30 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/**@file Handle compatibility and deprecations across Qt versions + * + * Since Calamares is supposed to work with Qt 5.15 or Qt 6 or later, it + * covers a lot of changes in the Qt API. + * + * This file adjusts for that by introducing suitable aliases + * and workaround-functions. + * + * For a similar approach for QtCore, see libcalamares/utils/String.h + */ + +#ifndef UTILS_QTCOMPAT_H +#define UTILS_QTCOMPAT_H + +#include + +/* Avoid warnings about QPalette changes */ +constexpr static const auto WindowBackground = QPalette::Window; + +constexpr static const auto WindowText = QPalette::WindowText; + +#endif diff --git a/calamares/src/libcalamaresui/utils/TestPaste.cpp b/calamares/src/libcalamaresui/utils/TestPaste.cpp new file mode 100644 index 0000000..02fe2d2 --- /dev/null +++ b/calamares/src/libcalamaresui/utils/TestPaste.cpp @@ -0,0 +1,86 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * + * Calamares is Free Software: see the License-Identifier above. + * + * + */ + +#include "Paste.h" +#include "network/Manager.h" + +#include "utils/Logger.h" + +#include +#include + +extern QByteArray logFileContents( qint64 sizeLimitBytes ); +extern QString ficheLogUpload( const QByteArray& pasteData, const QUrl& serverUrl, QObject* parent ); + +class TestPaste : public QObject +{ + Q_OBJECT + +public: + TestPaste() {} + ~TestPaste() override {} + +private Q_SLOTS: + void testGetLogFile(); + void testFichePaste(); + void testUploadSize(); +}; + +void +TestPaste::testGetLogFile() +{ + QFile::remove( Logger::logFile() ); + // This test assumes nothing **else** has set up logging yet + QByteArray logLimitedBefore = logFileContents( 16 ); + QVERIFY( logLimitedBefore.isEmpty() ); + QByteArray logUnlimitedBefore = logFileContents( -1 ); + QVERIFY( logUnlimitedBefore.isEmpty() ); + + Logger::setupLogLevel( Logger::LOGDEBUG ); + Logger::setupLogfile(); + + QByteArray logLimitedAfter = logFileContents( 16 ); + QVERIFY( !logLimitedAfter.isEmpty() ); + QByteArray logUnlimitedAfter = logFileContents( -1 ); + QVERIFY( !logUnlimitedAfter.isEmpty() ); +} + +void +TestPaste::testFichePaste() +{ + QString blabla( "the quick brown fox tested Calamares and found it rubbery" ); + QDateTime now = QDateTime::currentDateTime(); + + QByteArray d = ( blabla + now.toString() ).toUtf8(); + QString s = ficheLogUpload( d, QUrl( "http://termbin.com:9999" ), nullptr ); + + cDebug() << "Paste data to" << s; + QVERIFY( !s.isEmpty() ); +} + +void +TestPaste::testUploadSize() +{ + QByteArray logContent = logFileContents( 100 ); + QString s = ficheLogUpload( logContent, QUrl( "http://termbin.com:9999" ), nullptr ); + + QVERIFY( !s.isEmpty() ); + + QUrl url( s ); + QByteArray returnedData = Calamares::Network::Manager().synchronousGet( url ); + + QCOMPARE( returnedData.size(), 100 ); +} +QTEST_GUILESS_MAIN( TestPaste ) + +#include "utils/moc-warnings.h" + +#include "TestPaste.moc" diff --git a/calamares/src/libcalamaresui/viewpages/BlankViewStep.cpp b/calamares/src/libcalamaresui/viewpages/BlankViewStep.cpp new file mode 100644 index 0000000..8f101af --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/BlankViewStep.cpp @@ -0,0 +1,110 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "BlankViewStep.h" + +#include "utils/Gui.h" + +#include +#include +#include + +namespace Calamares +{ + +BlankViewStep::BlankViewStep( const QString& title, + const QString& description, + const QString& details, + QObject* parent ) + : Calamares::ViewStep( parent ) + , m_widget( new QWidget() ) +{ + QBoxLayout* layout = new QVBoxLayout(); + + constexpr int const marginWidth = 10; + constexpr int const spacingHeight = 10; + + auto* label = new QLabel( title ); + label->setAlignment( Qt::AlignHCenter ); + label->setFont( Calamares::largeFont() ); + layout->addWidget( label ); + + label = new QLabel( description ); + label->setWordWrap( true ); + label->setMargin( marginWidth ); + layout->addSpacing( spacingHeight ); + layout->addWidget( label ); + + if ( !details.isEmpty() ) + { + label = new QLabel( details ); + label->setMargin( marginWidth ); + layout->addSpacing( spacingHeight ); + layout->addWidget( label ); + } + + layout->addStretch( 1 ); // Push the rest to the top + + m_widget->setLayout( layout ); +} + +BlankViewStep::~BlankViewStep() {} + +QString +BlankViewStep::prettyName() const +{ + return tr( "Blank Page" ); +} + +void +BlankViewStep::back() +{ +} + +void +BlankViewStep::next() +{ +} + +bool +BlankViewStep::isBackEnabled() const +{ + return false; +} + +bool +BlankViewStep::isNextEnabled() const +{ + return false; +} + +bool +BlankViewStep::isAtBeginning() const +{ + return true; +} + +bool +BlankViewStep::isAtEnd() const +{ + return false; +} + +QWidget* +BlankViewStep::widget() +{ + return m_widget; +} + +Calamares::JobList +BlankViewStep::jobs() const +{ + return JobList(); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/viewpages/BlankViewStep.h b/calamares/src/libcalamaresui/viewpages/BlankViewStep.h new file mode 100644 index 0000000..1845fcd --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/BlankViewStep.h @@ -0,0 +1,54 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef BLANKVIEWSTEP_H +#define BLANKVIEWSTEP_H + +#include "viewpages/ViewStep.h" + +namespace Calamares +{ + +/** @brief A "blank" view step, used for error and status reporting + * + * This view step never allows navigation (forward or back); it's a trap. + * It displays a title and explanation, and optional details. + */ +class BlankViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit BlankViewStep( const QString& title, + const QString& description, + const QString& details = QString(), + QObject* parent = nullptr ); + ~BlankViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + void next() override; + void back() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + +private: + QWidget* m_widget; +}; + +} // namespace Calamares +#endif // BLANKVIEWSTEP_H diff --git a/calamares/src/libcalamaresui/viewpages/ExecutionViewStep.cpp b/calamares/src/libcalamaresui/viewpages/ExecutionViewStep.cpp new file mode 100644 index 0000000..71ea85b --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/ExecutionViewStep.cpp @@ -0,0 +1,244 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ExecutionViewStep.h" + +#include "Slideshow.h" + +#include "Branding.h" +#include "CalamaresConfig.h" +#include "Job.h" +#include "JobQueue.h" +#include "Settings.h" +#include "ViewManager.h" +#include "modulesystem/Module.h" +#include "modulesystem/ModuleManager.h" +#include "utils/Dirs.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "widgets/LogWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static Calamares::Slideshow* +makeSlideshow( QWidget* parent ) +{ + const int api = Calamares::Branding::instance()->slideshowAPI(); + switch ( api ) + { + case -1: + return new Calamares::SlideshowPictures( parent ); +#ifdef WITH_QML + case 1: + [[fallthrough]]; + case 2: + return new Calamares::SlideshowQML( parent ); +#endif + default: + cWarning() << "Unknown Branding slideshow API" << api; + return new Calamares::SlideshowPictures( parent ); + } +} + +namespace Calamares +{ + +ExecutionViewStep::ExecutionViewStep( QObject* parent ) + : ViewStep( parent ) + , m_widget( new QWidget ) + , m_progressBar( new QProgressBar ) + , m_label( new QLabel ) + , m_slideshow( makeSlideshow( m_widget ) ) + , m_tab_widget( new QTabWidget ) + , m_log_widget( new LogWidget ) +{ + m_widget->setObjectName( "slideshow" ); + m_progressBar->setObjectName( "exec-progress" ); + CALAMARES_RETRANSLATE( m_progressBar->setFormat( + tr( "%p%", "Progress percentage indicator: %p is where the number 0..100 is placed" ) ); ); + m_label->setObjectName( "exec-message" ); + + QVBoxLayout* layout = new QVBoxLayout( m_widget ); + QVBoxLayout* bottomLayout = new QVBoxLayout; + QHBoxLayout* barLayout = new QHBoxLayout; + + m_progressBar->setMaximum( 10000 ); + + m_tab_widget->addTab( m_slideshow->widget(), "Slideshow" ); + m_tab_widget->addTab( m_log_widget, "Log" ); + m_tab_widget->tabBar()->hide(); + + layout->addWidget( m_tab_widget ); + Calamares::unmarginLayout( layout ); + layout->addLayout( bottomLayout ); + + bottomLayout->addSpacing( Calamares::defaultFontHeight() / 2 ); + bottomLayout->addLayout( barLayout ); + bottomLayout->addWidget( m_label ); + + QToolBar* toolBar = new QToolBar; + const auto logButtonIcon = QIcon::fromTheme( "utilities-terminal" ); + auto toggleLogAction = toolBar->addAction( + Branding::instance()->image( + { "utilities-log-viewer", "utilities-terminal", "text-x-log", "text-x-changelog", "preferences-log" }, + QSize( 32, 32 ) ), + "Toggle log" ); + auto toggleLogButton = dynamic_cast< QToolButton* >( toolBar->widgetForAction( toggleLogAction ) ); + connect( toggleLogButton, &QToolButton::clicked, this, &ExecutionViewStep::toggleLog ); + + barLayout->addWidget( m_progressBar ); + barLayout->addWidget( toolBar ); + + connect( JobQueue::instance(), &JobQueue::progress, this, &ExecutionViewStep::updateFromJobQueue ); +} + +QString +ExecutionViewStep::prettyName() const +{ + return Calamares::Settings::instance()->isSetupMode() ? tr( "Set Up", "@label" ) : tr( "Install", "@label" ); +} + +QWidget* +ExecutionViewStep::widget() +{ + return m_widget; +} + +void +ExecutionViewStep::next() +{ +} + +void +ExecutionViewStep::back() +{ +} + +bool +ExecutionViewStep::isNextEnabled() const +{ + return false; +} + +bool +ExecutionViewStep::isBackEnabled() const +{ + return false; +} + +bool +ExecutionViewStep::isAtBeginning() const +{ + return true; +} + +bool +ExecutionViewStep::isAtEnd() const +{ + return !JobQueue::instance()->isRunning(); +} + +void +ExecutionViewStep::onActivate() +{ + m_slideshow->changeSlideShowState( Slideshow::Start ); + + const auto instanceDescriptors = Calamares::Settings::instance()->moduleInstances(); + + JobQueue* queue = JobQueue::instance(); + for ( const auto& instanceKey : m_jobInstanceKeys ) + { + const auto& moduleDescriptor = Calamares::ModuleManager::instance()->moduleDescriptor( instanceKey ); + Calamares::Module* module = Calamares::ModuleManager::instance()->moduleInstance( instanceKey ); + + const auto instanceDescriptor + = std::find_if( instanceDescriptors.constBegin(), + instanceDescriptors.constEnd(), + [ = ]( const Calamares::InstanceDescription& d ) { return d.key() == instanceKey; } ); + int weight = moduleDescriptor.weight(); + if ( instanceDescriptor != instanceDescriptors.constEnd() && instanceDescriptor->explicitWeight() ) + { + weight = instanceDescriptor->weight(); + } + weight = qBound( 1, weight, 100 ); + if ( module ) + { + auto jl = module->jobs(); + if ( module->isEmergency() ) + { + for ( auto& j : jl ) + { + j->setEmergency( true ); + } + } + queue->enqueue( weight, jl ); + } + } + + queue->start(); +} + +JobList +ExecutionViewStep::jobs() const +{ + return JobList(); +} + +void +ExecutionViewStep::appendJobModuleInstanceKey( const ModuleSystem::InstanceKey& instanceKey ) +{ + m_jobInstanceKeys.append( instanceKey ); +} + +void +ExecutionViewStep::updateFromJobQueue( qreal percent, const QString& message ) +{ + m_progressBar->setValue( int( percent * m_progressBar->maximum() ) ); + if ( !message.isEmpty() ) + { + m_label->setText( message ); + } +} + +void +ExecutionViewStep::toggleLog() +{ + const bool logBecomesVisible = m_tab_widget->currentIndex() == 0; // ie. is not visible right now + if ( logBecomesVisible ) + { + m_log_widget->start(); + } + else + { + m_log_widget->stop(); + } + m_tab_widget->setCurrentIndex( logBecomesVisible ? 1 : 0 ); +} + +void +ExecutionViewStep::onLeave() +{ + m_log_widget->stop(); + m_slideshow->changeSlideShowState( Slideshow::Stop ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/viewpages/ExecutionViewStep.h b/calamares/src/libcalamaresui/viewpages/ExecutionViewStep.h new file mode 100644 index 0000000..df52e55 --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/ExecutionViewStep.h @@ -0,0 +1,80 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef EXECUTIONVIEWSTEP_H +#define EXECUTIONVIEWSTEP_H + +#include "ViewStep.h" +#include "modulesystem/InstanceKey.h" +#include "widgets/LogWidget.h" + +#include + +class QLabel; +class QObject; +class QProgressBar; +class QTabWidget; + +namespace Calamares +{ + +class Slideshow; + +/** + * @class + * + * This is the implementation of the special ViewStep "Install" + * which takes care of an *exec* phase in the sequence. It runs + * jobs, shows the slideshow, etc. + */ +class UIDLLEXPORT ExecutionViewStep : public ViewStep +{ + Q_OBJECT +public: + explicit ExecutionViewStep( QObject* parent = nullptr ); + + QString prettyName() const override; + + QWidget* widget() override; + + void next() override; + void back() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onActivate() override; + void onLeave() override; + + JobList jobs() const override; + + void appendJobModuleInstanceKey( const ModuleSystem::InstanceKey& instanceKey ); + +private: + QWidget* m_widget; + QProgressBar* m_progressBar; + QLabel* m_label; + Slideshow* m_slideshow; + QTabWidget* m_tab_widget; + LogWidget* m_log_widget; + + QList< ModuleSystem::InstanceKey > m_jobInstanceKeys; + + void updateFromJobQueue( qreal percent, const QString& message ); + + void toggleLog(); +}; + +} // namespace Calamares + +#endif /* EXECUTIONVIEWSTEP_H */ diff --git a/calamares/src/libcalamaresui/viewpages/QmlViewStep.cpp b/calamares/src/libcalamaresui/viewpages/QmlViewStep.cpp new file mode 100644 index 0000000..086f8fa --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/QmlViewStep.cpp @@ -0,0 +1,301 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "QmlViewStep.h" + +#include "Branding.h" +#include "ViewManager.h" + +#include "compat/Variant.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Qml.h" +#include "utils/Variant.h" +#include "widgets/WaitingWidget.h" + +#include +#include +#include +#include +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) +#include +#else +#include +#endif + +#include +#include + +/// @brief State-change of the QML, for changeQMLState() +enum class QMLAction +{ + Start, + Stop +}; + +/** @brief Tells the QML we activated or left it. + * + * If @p action is @c QMLAction::Start, calls onActivate in the QML. + * If @p action is @c QMLAction::Stop, calls onLeave in the QML. + * + * Sets *activatedInCalamares* property on the QML as well (to true + * if @p action is @c QMLAction::Start, false otherwise). + */ +static void +changeQMLState( QMLAction action, QQuickItem* item ) +{ + static const char propertyName[] = "activatedInCalamares"; + + bool activate = action == QMLAction::Start; + Calamares::callQmlFunction( item, activate ? "onActivate" : "onLeave" ); + + auto property = item->property( propertyName ); + if ( property.isValid() && ( Calamares::typeOf( property ) == Calamares::BoolVariantType ) + && ( property.toBool() != activate ) ) + { + item->setProperty( propertyName, activate ); + } +} + +namespace Calamares +{ + +QmlViewStep::QmlViewStep( QObject* parent ) + : ViewStep( parent ) + , m_widget( new QWidget ) + , m_spinner( new WaitingWidget( tr( "Loading…", "@status" ) ) ) +{ + Calamares::registerQmlModels(); + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + m_qmlWidget = new QQuickWidget; + m_qmlWidget->setResizeMode( QQuickWidget::SizeRootObjectToView ); + m_qmlWidget->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); + m_qmlEngine = m_qmlWidget->engine(); +#else + m_qmlEngine = new QQmlEngine( this ); +#endif + + QVBoxLayout* layout = new QVBoxLayout( m_widget ); + layout->addWidget( m_spinner ); + + m_qmlEngine->addImportPath( Calamares::qmlModulesDir().absolutePath() ); + + // QML Loading starts when the configuration for the module is set. +} + +QmlViewStep::~QmlViewStep() {} + +QString +QmlViewStep::prettyName() const +{ + // TODO: query the QML itself + return tr( "QML step %1.", "@label" ).arg( moduleInstanceKey().module() ); +} + +bool +QmlViewStep::isAtBeginning() const +{ + return true; +} + +bool +QmlViewStep::isAtEnd() const +{ + return true; +} +bool +QmlViewStep::isBackEnabled() const +{ + return true; +} + +bool +QmlViewStep::isNextEnabled() const +{ + return true; +} + +Calamares::JobList +QmlViewStep::jobs() const +{ + return JobList(); +} + +void +QmlViewStep::onActivate() +{ + if ( m_qmlObject ) + { + changeQMLState( QMLAction::Start, m_qmlObject ); + } +} + +void +QmlViewStep::onLeave() +{ + if ( m_qmlObject ) + { + changeQMLState( QMLAction::Stop, m_qmlObject ); + } +} + +QWidget* +QmlViewStep::widget() +{ + return m_widget; +} + +QSize +QmlViewStep::widgetMargins( Qt::Orientations panelSides ) +{ + // If any panels around it, use the standard, but if all the + // panels are hidden, like on full-screen with subsumed navigation, + // then no margins. + if ( panelSides ) + { + return ViewStep::widgetMargins( panelSides ); + } + else + { + return QSize( 0, 0 ); + } +} + +void +QmlViewStep::loadComplete() +{ + cDebug() << "QML component" << m_qmlFileName << m_qmlComponent->status(); + if ( m_qmlComponent->status() == QQmlComponent::Error ) + { + showFailedQml(); + } + if ( m_qmlComponent->isReady() && !m_qmlObject ) + { + cDebug() << Logger::SubEntry << "QML component complete" << m_qmlFileName << "creating object"; + // Don't do this again + disconnect( m_qmlComponent, &QQmlComponent::statusChanged, this, &QmlViewStep::loadComplete ); + + QObject* o = m_qmlComponent->create(); + m_qmlObject = qobject_cast< QQuickItem* >( o ); + if ( !m_qmlObject ) + { + cError() << Logger::SubEntry << "Could not create QML from" << m_qmlFileName; + delete o; + } + else + { +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + // setContent() is public API, but not documented publicly. + // It is marked \internal in the Qt sources, but does exactly + // what is needed: sets up visual parent by replacing the root + // item, and handling resizes. + m_qmlWidget->setContent( QUrl( m_qmlFileName ), m_qmlComponent, m_qmlObject ); +#else + auto* quick = new QQuickWindow; + auto* root = quick->contentItem(); + m_qmlObject->setParentItem( root ); + m_qmlObject->bindableWidth().setBinding( [ = ]() { return root->width(); } ); + m_qmlObject->bindableHeight().setBinding( [ = ]() { return root->height(); } ); + m_qmlWidget = QWidget::createWindowContainer( quick, m_widget ); +#endif + showQml(); + } + } +} + +void +QmlViewStep::showQml() +{ + if ( !m_qmlWidget || !m_qmlObject ) + { + cWarning() << "showQml() called but no QML object"; + return; + } + if ( m_spinner ) + { + m_widget->layout()->removeWidget( m_spinner ); + m_widget->layout()->addWidget( m_qmlWidget ); + delete m_spinner; + m_spinner = nullptr; + } + else + { + cWarning() << "showQml() called twice"; + } + + if ( ViewManager::instance()->currentStep() == this ) + { + // We're alreay visible! Must have been slow QML loading, and we + // passed onActivate already. + changeQMLState( QMLAction::Start, m_qmlObject ); + } +} + +void +QmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + bool ok = false; + m_searchMethod = Calamares::qmlSearchNames().find( Calamares::getString( configurationMap, "qmlSearch" ), ok ); + if ( !ok ) + { + cWarning() << "Bad QML search mode set for" << moduleInstanceKey(); + } + + QString qmlFile = Calamares::getString( configurationMap, "qmlFilename" ); + if ( !m_qmlComponent ) + { + m_qmlFileName = searchQmlFile( m_searchMethod, qmlFile, moduleInstanceKey() ); + + QObject* config = this->getConfig(); + if ( config ) + { + setContextProperty( "config", config ); + } + + cDebug() << "QmlViewStep" << moduleInstanceKey() << "loading" << m_qmlFileName; + m_qmlComponent + = new QQmlComponent( m_qmlEngine, QUrl( m_qmlFileName ), QQmlComponent::CompilationMode::Asynchronous ); + connect( m_qmlComponent, &QQmlComponent::statusChanged, this, &QmlViewStep::loadComplete ); + if ( m_qmlComponent->status() == QQmlComponent::Error ) + { + showFailedQml(); + } + } + else + { + cWarning() << "QML configuration set after component" << moduleInstanceKey() << "has loaded."; + } +} + +void +QmlViewStep::showFailedQml() +{ + cWarning() << "QmlViewStep" << moduleInstanceKey() << "loading failed."; + if ( m_qmlComponent ) + { + cDebug() << Logger::SubEntry << "QML error:" << m_qmlComponent->errorString(); + } + m_spinner->setText( prettyName() + ' ' + tr( "Loading failed.", "@info" ) ); +} + +QObject* +QmlViewStep::getConfig() +{ + return nullptr; +} + +void +QmlViewStep::setContextProperty( const char* name, QObject* property ) +{ + m_qmlEngine->rootContext()->setContextProperty( name, property ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/viewpages/QmlViewStep.h b/calamares/src/libcalamaresui/viewpages/QmlViewStep.h new file mode 100644 index 0000000..0d9f6cf --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/QmlViewStep.h @@ -0,0 +1,127 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef QMLVIEWSTEP_H +#define QMLVIEWSTEP_H + +#include "DllMacro.h" +#include "utils/Qml.h" +#include "viewpages/ViewStep.h" + +class QQmlComponent; +class QQmlEngine; +class QQuickItem; +class QQuickWidget; +class WaitingWidget; + +namespace Calamares +{ + +/** @brief A viewstep that uses QML for the UI + * + * This is generally a **base** class for other view steps, but + * it can be used stand-alone for viewsteps that don't really have + * any functionality. + * + * Most subclasses will override the following methods: + * - prettyName() to provide a meaningful human-readable name + * - jobs() if there is real work to be done during installation + * - getConfig() to return a meaningful configuration object + * + * For details on the interaction between the config object and + * the QML in the module, see the module documentation: + * src/modules/README.md + */ +class UIDLLEXPORT QmlViewStep : public Calamares::ViewStep +{ + Q_OBJECT +public: + /** @brief Creates a QML view step + * + * The search behavior for the actial QML depends on a QmlSearch value. + * This is set through common configuration key *qmlSearch*. + * The filename used comes from the module identifier, or can be + * set in the configuration file through *qmlFilename*. + * + * @see Qml.h for available Calamares internals. + */ + QmlViewStep( QObject* parent = nullptr ); + ~QmlViewStep() override; + + virtual QString prettyName() const override; + + virtual QWidget* widget() override; + virtual QSize widgetMargins( Qt::Orientations panelSides ) override; + + virtual bool isNextEnabled() const override; + virtual bool isBackEnabled() const override; + + virtual bool isAtBeginning() const override; + virtual bool isAtEnd() const override; + + virtual void onActivate() override; + virtual void onLeave() override; + + /// @brief QML widgets don't produce jobs by default + virtual JobList jobs() const override; + + /// @brief Configure search paths; subclasses should call this at the **end** of their own implementation + virtual void setConfigurationMap( const QVariantMap& configurationMap ) override; + +protected: + /** @brief Gets a pointer to the Config of this view step + * + * Parts of the configuration of the viewstep can be passed to QML + * by placing them in a QObject (as properties). The default + * implementation returns nullptr, for no-config. + * + * Ownership of the config object remains with the ViewStep; it is possible + * to return a pointer to a member variable. + * + * This object is made available as a context-property *config* in QML. + */ + virtual QObject* getConfig(); + + /** @brief Adds a context property for this QML file + * + * Does not take ownership. + */ + void setContextProperty( const char* name, QObject* property ); + +private Q_SLOTS: + void loadComplete(); + +private: + /// @brief Swap out the spinner for the QQuickWidget + void showQml(); + /// @brief Show error message in spinner. + void showFailedQml(); + + /// @brief Controls where m_name is searched + Calamares::QmlSearch m_searchMethod; + + QString m_name; + QString m_qmlFileName; + + QWidget* m_widget = nullptr; + WaitingWidget* m_spinner = nullptr; + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + QQuickWidget* m_qmlWidget = nullptr; +#else + QWidget* m_qmlWidget = nullptr; // Qt6: container for QQuickWindow +#endif + + QQmlEngine* m_qmlEngine = nullptr; // Qt5: points to QuickWidget engine, Qt6: separate engine + QQmlComponent* m_qmlComponent = nullptr; + QQuickItem* m_qmlObject = nullptr; +}; + +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamaresui/viewpages/Slideshow.cpp b/calamares/src/libcalamaresui/viewpages/Slideshow.cpp new file mode 100644 index 0000000..f20ef63 --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/Slideshow.cpp @@ -0,0 +1,283 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Slideshow.h" + +#include "Branding.h" +#include "compat/Mutex.h" +#include "compat/Variant.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" +#ifdef WITH_QML +#include "utils/Qml.h" +#endif +#include "utils/Retranslator.h" + +#include +#include +#ifdef WITH_QML +#include +#include +#include +#include +#endif +#include + +#include + +namespace Calamares +{ + +Slideshow::~Slideshow() {} + +#ifdef WITH_QML +SlideshowQML::SlideshowQML( QWidget* parent ) + : Slideshow( parent ) + , m_qmlShow( new QQuickWidget ) + , m_qmlComponent( nullptr ) + , m_qmlObject( nullptr ) +{ + m_qmlShow->setObjectName( "qml" ); + + Calamares::registerQmlModels(); + + m_qmlShow->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); + m_qmlShow->setResizeMode( QQuickWidget::SizeRootObjectToView ); + m_qmlShow->engine()->addImportPath( Calamares::qmlModulesDir().absolutePath() ); + + cDebug() << "QML import paths:" << Logger::DebugList( m_qmlShow->engine()->importPathList() ); + CALAMARES_RETRANSLATE( if ( m_qmlShow ) { m_qmlShow->engine()->retranslate(); } ); + + if ( Branding::instance()->slideshowAPI() == 2 ) + { + cDebug() << "QML load on startup, API 2."; + loadQmlV2(); + } +} + +SlideshowQML::~SlideshowQML() +{ + delete m_qmlObject; + delete m_qmlComponent; + delete m_qmlShow; +} + +QWidget* +SlideshowQML::widget() +{ + return m_qmlShow; +} + +void +SlideshowQML::loadQmlV2() +{ + Calamares::MutexLocker l( &m_mutex ); + if ( !m_qmlComponent && !Calamares::Branding::instance()->slideshowPath().isEmpty() ) + { + m_qmlComponent = new QQmlComponent( m_qmlShow->engine(), + QUrl::fromLocalFile( Calamares::Branding::instance()->slideshowPath() ), + QQmlComponent::CompilationMode::Asynchronous ); + connect( m_qmlComponent, &QQmlComponent::statusChanged, this, &SlideshowQML::loadQmlV2Complete ); + } +} + +void +SlideshowQML::loadQmlV2Complete() +{ + Calamares::MutexLocker l( &m_mutex ); + if ( m_qmlComponent && m_qmlComponent->isReady() && !m_qmlObject ) + { + cDebug() << "QML component complete, API 2"; + // Don't do this again + disconnect( m_qmlComponent, &QQmlComponent::statusChanged, this, &SlideshowQML::loadQmlV2Complete ); + + QObject* o = m_qmlComponent->create(); + m_qmlObject = qobject_cast< QQuickItem* >( o ); + if ( !m_qmlObject ) + { + delete o; + } + else + { + cDebug() << Logger::SubEntry << "Loading" << Calamares::Branding::instance()->slideshowPath(); + + // setContent() is public API, but not documented publicly. + // It is marked \internal in the Qt sources, but does exactly + // what is needed: sets up visual parent by replacing the root + // item, and handling resizes. + m_qmlShow->setContent( + QUrl::fromLocalFile( Calamares::Branding::instance()->slideshowPath() ), m_qmlComponent, m_qmlObject ); + if ( isActive() ) + { + // We're alreay visible! Must have been slow QML loading, and we + // passed onActivate already. changeSlideShowState() locks + // the same mutex: call changeSlideShowState() after l is dead. + QTimer::singleShot( 0, this, &SlideshowQML::startSlideShow ); + } + } + } + else + { + if ( m_qmlObject ) + { + cWarning() << "QML object already created"; + } + else if ( !m_qmlComponent ) + { + cWarning() << "QML component does not exist"; + } + else if ( m_qmlComponent && !m_qmlComponent->isReady() ) + { + cWarning() << "QML component not ready:" << m_qmlComponent->errors(); + } + } +} + +void +SlideshowQML::startSlideShow() +{ + changeSlideShowState( Slideshow::Start ); +} + +/* + * Applies V1 and V2 QML activation / deactivation: + * - V1 loads the QML in @p widget on activation. Sets root object property + * *activatedInCalamares* as appropriate. + * - V2 calls onActivate() or onLeave() in the QML as appropriate. Also + * sets the *activatedInCalamares* property. + */ +void +SlideshowQML::changeSlideShowState( Action state ) +{ + Calamares::MutexLocker l( &m_mutex ); + bool activate = state == Slideshow::Start; + + if ( Branding::instance()->slideshowAPI() == 2 ) + { + // The QML was already loaded in the constructor, need to start it + Calamares::callQmlFunction( m_qmlObject, activate ? "onActivate" : "onLeave" ); + } + else if ( !Calamares::Branding::instance()->slideshowPath().isEmpty() ) + { + // API version 1 assumes onCompleted is the trigger + if ( activate ) + { + m_qmlShow->setSource( QUrl::fromLocalFile( Calamares::Branding::instance()->slideshowPath() ) ); + } + // needs the root object for property setting, below + m_qmlObject = m_qmlShow->rootObject(); + } + + // V1 API has picked up the root object for use, V2 passed it in. + if ( m_qmlObject ) + { + static const char propertyName[] = "activatedInCalamares"; + auto property = m_qmlObject->property( propertyName ); + if ( property.isValid() && ( Calamares::typeOf( property ) == Calamares::BoolVariantType ) + && ( property.toBool() != activate ) ) + { + m_qmlObject->setProperty( propertyName, activate ); + } + } + + if ( ( Branding::instance()->slideshowAPI() == 2 ) && ( state == Slideshow::Stop ) ) + { + delete m_qmlObject; + m_qmlObject = nullptr; + } + + m_state = state; +} +#endif + +SlideshowPictures::SlideshowPictures( QWidget* parent ) + : Slideshow( parent ) + , m_label( new QLabel( parent ) ) + , m_timer( new QTimer( this ) ) + , m_imageIndex( 0 ) + , m_images( Branding::instance()->slideshowImages() ) +{ + m_label->setObjectName( "image" ); + + m_label->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); + m_label->setAlignment( Qt::AlignCenter ); + m_timer->setInterval( std::chrono::milliseconds( 2000 ) ); + connect( m_timer, &QTimer::timeout, this, &SlideshowPictures::next ); +} + +SlideshowPictures::~SlideshowPictures() +{ + delete m_timer; + delete m_label; +} + +QWidget* +SlideshowPictures::widget() +{ + return m_label; +} + +void +SlideshowPictures::changeSlideShowState( Calamares::Slideshow::Action a ) +{ + Calamares::MutexLocker l( &m_mutex ); + m_state = a; + if ( a == Slideshow::Start ) + { + m_imageIndex = -1; + if ( m_images.count() < 1 ) + { + m_label->setPixmap( QPixmap( ":/data/images/squid.svg" ) ); + } + else + { + + m_timer->start(); + QTimer::singleShot( 0, this, &SlideshowPictures::next ); + } + } + else + { + m_timer->stop(); + } +} + +void +SlideshowPictures::next() +{ + Calamares::MutexLocker l( &m_mutex ); + + if ( m_imageIndex < 0 ) + { + // Initialization, don't do the advance-by-one + m_imageIndex = 0; + } + else + { + m_imageIndex++; + if ( m_imageIndex >= m_images.count() ) + { + m_imageIndex = 0; + } + } + + if ( m_imageIndex >= m_images.count() ) + { + // Unusual case: timer is running, but we have 0 images to display. + // .. this would have been caught in changeSlideShowState(), which + // .. special-cases 0 images. + return; + } + + m_label->setPixmap( QPixmap( m_images.at( m_imageIndex ) ) ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/viewpages/Slideshow.h b/calamares/src/libcalamaresui/viewpages/Slideshow.h new file mode 100644 index 0000000..41dacd4 --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/Slideshow.h @@ -0,0 +1,142 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARESUI_SLIDESHOW_H +#define LIBCALAMARESUI_SLIDESHOW_H + +#include "CalamaresConfig.h" + +#include +#include +#include + +class QLabel; +class QTimer; +#ifdef WITH_QML +class QQmlComponent; +class QQuickItem; +class QQuickWidget; +#endif + +namespace Calamares +{ + +/** @brief API for Slideshow objects + * + * A Slideshow (subclass) object is created by the ExecutionViewStep + * and needs to manage its own configuration (e.g. from Branding). + * The slideshow is started and stopped when it becomes visible + * and when installation is over, by calling changeSlideShowState() + * as appropriate. + */ +class Slideshow : public QObject +{ + Q_OBJECT +public: + /// @brief State-change of the slideshow, for changeSlideShowState() + enum Action + { + Start, + Stop + }; + + Slideshow( QWidget* parent = nullptr ) + : QObject( parent ) + { + } + ~Slideshow() override; + + /// @brief Is the slideshow being shown **right now**? + bool isActive() const { return m_state == Start; } + + /** @brief The actual widget to show the user. + * + * Depending on the style of slideshow, this might be a QQuickWidget, + * or a QLabel, or something else entirely. + */ + virtual QWidget* widget() = 0; + + /** @brief Tells the slideshow we activated or left the show. + * + * If @p state is @c Slideshow::Start, calls suitable activation procedures. + * If @p state is @c Slideshow::Stop, calls deactivation procedures. + */ + virtual void changeSlideShowState( Action a ) = 0; + +protected: + QMutex m_mutex; + Action m_state = Stop; +}; + +#ifdef WITH_QML +/** @brief Slideshow using a QML file + * + * This is the "classic" slideshow in Calamares, which runs some QML + * while the installation is in progress. It is configured through + * Branding settings *slideshow* and *slideshowAPI*, showing the QML + * file from *slideshow*. The API version influences when and how the + * QML is loaded; version 1 does so only when the slideshow is activated, + * while version 2 does so asynchronously. + */ +class SlideshowQML : public Slideshow +{ + Q_OBJECT +public: + SlideshowQML( QWidget* parent ); + ~SlideshowQML() override; + + QWidget* widget() override; + void changeSlideShowState( Action a ) override; + +public slots: + void loadQmlV2Complete(); + void loadQmlV2(); ///< Loads the slideshow QML (from branding) for API version 2 + + /// Implementation detail + void startSlideShow(); + +private: + QQuickWidget* m_qmlShow; + QQmlComponent* m_qmlComponent; + QQuickItem* m_qmlObject; ///< The actual show +}; +#endif + +/** @brief Slideshow using images + * + * This is an "oldschool" slideshow, but new in Calamares, which + * displays static image files one-by-one. It is for systems that + * do not use QML at all. It is configured through the Branding + * setting *slideshow*. When using this widget, the setting must + * be a list of filenames; the API is set to -1. + */ +class SlideshowPictures : public Slideshow +{ + Q_OBJECT +public: + SlideshowPictures( QWidget* parent ); + ~SlideshowPictures() override; + + QWidget* widget() override; + virtual void changeSlideShowState( Action a ) override; + +public slots: + void next(); + +private: + QLabel* m_label; + QTimer* m_timer; + int m_imageIndex; + QStringList m_images; +}; + +} // namespace Calamares +#endif diff --git a/calamares/src/libcalamaresui/viewpages/ViewStep.cpp b/calamares/src/libcalamaresui/viewpages/ViewStep.cpp new file mode 100644 index 0000000..4a172a9 --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/ViewStep.cpp @@ -0,0 +1,91 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ViewStep.h" + +#include +#include + +namespace Calamares +{ + +ViewStep::ViewStep( QObject* parent ) + : QObject( parent ) +{ +} + +ViewStep::~ViewStep() {} + +QString +ViewStep::prettyStatus() const +{ + return QString(); +} + +QWidget* +ViewStep::createSummaryWidget() const +{ + return nullptr; +} + +void +ViewStep::onActivate() +{ +} + +void +ViewStep::onLeave() +{ +} + +void +ViewStep::onCancel() +{ +} + +void +ViewStep::next() +{ +} + +void +ViewStep::back() +{ +} + +void +ViewStep::setModuleInstanceKey( const Calamares::ModuleSystem::InstanceKey& instanceKey ) +{ + m_instanceKey = instanceKey; +} + +void +ViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + Q_UNUSED( configurationMap ) +} + +RequirementsList +ViewStep::checkRequirements() +{ + return RequirementsList(); +} + +QSize +ViewStep::widgetMargins( Qt::Orientations panelSides ) +{ + Q_UNUSED( panelSides ) + + // Application's default style + const auto* s = QApplication::style(); + return QSize( s->pixelMetric( QStyle::PM_LayoutLeftMargin ), s->pixelMetric( QStyle::PM_LayoutTopMargin ) ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/viewpages/ViewStep.h b/calamares/src/libcalamaresui/viewpages/ViewStep.h new file mode 100644 index 0000000..1a52ef6 --- /dev/null +++ b/calamares/src/libcalamaresui/viewpages/ViewStep.h @@ -0,0 +1,199 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef VIEWSTEP_H +#define VIEWSTEP_H + +#include "DllMacro.h" +#include "Job.h" + +#include "modulesystem/InstanceKey.h" +#include "modulesystem/Requirement.h" + +#include +#include +#include + +namespace Calamares +{ + +/** + * @brief The ViewStep class is the base class for all view modules. + * A view module is a Calamares module which has at least one UI page (exposed as + * ViewStep::widget), and can optionally create Calamares jobs at runtime. + * As of early 2020, a view module can be implemented by deriving from ViewStep + * in C++ (as a Qt Plugin or a Qml ViewStep). + * + * A ViewStep can describe itself in human-readable format for the SummaryPage + * (which shows all of the things which have been collected to be done in the + * next exec-step) through prettyStatus() and createSummaryWidget(). + */ +class UIDLLEXPORT ViewStep : public QObject +{ + Q_OBJECT +public: + explicit ViewStep( QObject* parent = nullptr ); + ~ViewStep() override; + + /** @brief Human-readable name of the step + * + * This (translated) string is shown in the sidebar (progress) + * and during installation. There is no default. + */ + virtual QString prettyName() const = 0; + + /** @brief Describe what this step will do during install + * + * Optional. May return a non-empty string describing what this + * step is going to do (should be translated). This is also used + * in the summary page to describe what is going to be done. + * Return an empty string to provide no description. + * + * The default implementation returns an empty string, so nothing + * will be displayed for this step when a summary is shown. + */ + virtual QString prettyStatus() const; + + /** @brief Return a long description what this step will do during install + * + * Optional. May return a widget which will be inserted in the summary + * page. The caller takes ownership of the widget. Return nullptr to + * provide no widget. In general, this is only used for complicated + * steps where prettyStatus() is not sufficient. + * + * The default implementation returns nullptr, so nothing + * will be displayed for this step when a summary is shown. + */ + virtual QWidget* createSummaryWidget() const; + + /** @brief Get (or create) the widget for this view step + * + * While a view step **may** create the widget when it is loaded, + * it is recommended to wait with widget creation until the + * widget is actually asked for: a view step **may** be used + * without a UI. + */ + virtual QWidget* widget() = 0; + + /** @brief Get margins for this widget + * + * This is called by the layout manager to find the desired + * margins (width is used for left and right margin, height is + * used for top and bottom margins) for the widget. The + * @p panelSides indicates where there are panels in the overall + * layout: horizontally and / or vertically adjacent (or none!) + * to the view step's widget. + * + * Should return a size based also on QStyle metrics for layout. + * The default implementation just returns the default layout metrics + * (often 11 pixels on a side). + */ + virtual QSize widgetMargins( Qt::Orientations panelSides ); + + /** + * @brief Multi-page support, go next + * + * Multi-page view steps need to manage the content visible in the widget + * themselves. This method is called when the user clicks the *next* + * button, and should switch to the next of the multiple-pages. It needs + * to be consistent with both isNextEnabled() and isAtEnd(). + * + * In particular: when isAtEnd() returns false, next() is called when + * the user clicks the button and a new page should be shown by this + * view step. When isAtEnd() returns true, clicking the button will + * switch to the next view step in sequence, rather than a next page + * in the current view step. + */ + virtual void next(); + /// @brief Multi-page support, go back + virtual void back(); + + /// @brief Can the user click *next* with currently-filled-in data? + virtual bool isNextEnabled() const = 0; + /// @brief Can the user click *previous* with currently-filled-in data? + virtual bool isBackEnabled() const = 0; + + /** + * @brief Multi-page support, switch to previous view step? + * + * For a multi-page view step, this indicates that the first (beginning) + * page is showing. Clicking *previous* when at the beginning of a view + * step, switches to the previous step, not the previous page of the + * current view step. + */ + virtual bool isAtBeginning() const = 0; + /// @brief Multi-page support, switch to next view step? + virtual bool isAtEnd() const = 0; + + /** + * @brief onActivate called every time a ViewStep is shown, either by going forward + * or backward. + * The default implementation does nothing. + */ + virtual void onActivate(); + + /** + * @brief onLeave called every time a ViewStep is hidden and control passes to + * another ViewStep, either by going forward or backward. + * The default implementation does nothing. + */ + virtual void onLeave(); + + /** + * @brief Jobs needed to run this viewstep + * + * When a ViewStep is listed in the exec section, its jobs are executed instead. + * This function returns that list of jobs; an empty list is ok. + */ + virtual JobList jobs() const = 0; + + void setModuleInstanceKey( const Calamares::ModuleSystem::InstanceKey& instanceKey ); + Calamares::ModuleSystem::InstanceKey moduleInstanceKey() const { return m_instanceKey; } + + virtual void setConfigurationMap( const QVariantMap& configurationMap ); + + /** + * @brief Can this module proceed, on this machine? + * + * This is called asynchronously at startup, and returns a list of + * the requirements that the module has checked, and their status. + * See Calamares::RequirementEntry for details. + */ + virtual RequirementsList checkRequirements(); + + /** + * @brief Called when the user cancels the installation + * + * View steps are expected to leave the system unchanged when + * the installation is cancelled. The default implementation + * does nothing. + */ + virtual void onCancel(); + +signals: + /// @brief Tells the viewmanager to enable the *next* button according to @p status + void nextStatusChanged( bool status ); + + /* Emitted when the viewstep thinks it needs more space than is currently + * available for display. @p size is the requested space, that is needed + * to display the entire page. + * + * This request may be silently ignored. + */ + void ensureSize( QSize enlarge ) const; + +protected: + Calamares::ModuleSystem::InstanceKey m_instanceKey; +}; + +using ViewStepList = QList< ViewStep* >; +} // namespace Calamares + +#endif // VIEWSTEP_H diff --git a/calamares/src/libcalamaresui/widgets/ClickableLabel.cpp b/calamares/src/libcalamaresui/widgets/ClickableLabel.cpp new file mode 100644 index 0000000..8475a5a --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/ClickableLabel.cpp @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ClickableLabel.h" + +#include // for doubleClickInterval() + +namespace Calamares +{ +namespace Widgets +{ + +ClickableLabel::ClickableLabel( QWidget* parent ) + : QLabel( parent ) +{ +} + + +ClickableLabel::ClickableLabel( const QString& text, QWidget* parent ) + : QLabel( text, parent ) +{ +} + + +ClickableLabel::~ClickableLabel() {} + + +void +ClickableLabel::mousePressEvent( QMouseEvent* event ) +{ + QLabel::mousePressEvent( event ); + m_time.start(); +} + + +void +ClickableLabel::mouseReleaseEvent( QMouseEvent* event ) +{ + QLabel::mouseReleaseEvent( event ); + if ( m_time.elapsed() < qApp->doubleClickInterval() ) + { + emit clicked(); + } +} +} // namespace Widgets +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/widgets/ClickableLabel.h b/calamares/src/libcalamaresui/widgets/ClickableLabel.h new file mode 100644 index 0000000..259f4a7 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/ClickableLabel.h @@ -0,0 +1,51 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARESUI_WIDGETS_CLICKABLELABEL_H +#define LIBCALAMARESUI_WIDGETS_CLICKABLELABEL_H + +#include +#include + +#include "DllMacro.h" + +namespace Calamares +{ +namespace Widgets +{ +/** @brief A Label where the whole label area is clickable + * + * When clicking anywhere on the Label (text, background, whatever) + * the signal clicked() is emitted. Use this as a buddy for radio + * buttons or other clickable things where you want mouse interaction + * with the label, to be the same as mouse interaction with the control. + */ +class UIDLLEXPORT ClickableLabel : public QLabel +{ + Q_OBJECT +public: + explicit ClickableLabel( QWidget* parent = nullptr ); + explicit ClickableLabel( const QString& text, QWidget* parent = nullptr ); + ~ClickableLabel() override; + +signals: + void clicked(); + +protected: + virtual void mousePressEvent( QMouseEvent* event ) override; + virtual void mouseReleaseEvent( QMouseEvent* event ) override; + +private: + QElapsedTimer m_time; +}; +} // namespace Widgets +} // namespace Calamares + +#endif // LIBCALAMARESUI_WIDGETS_CLICKABLELABEL_H diff --git a/calamares/src/libcalamaresui/widgets/ErrorDialog.cpp b/calamares/src/libcalamaresui/widgets/ErrorDialog.cpp new file mode 100644 index 0000000..8c682f9 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/ErrorDialog.cpp @@ -0,0 +1,106 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Artem Grinev + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ErrorDialog.h" +#include "ui_ErrorDialog.h" + +#include "widgets/TranslationFix.h" +#include +#include + +namespace Calamares +{ + +ErrorDialog::ErrorDialog( QWidget* parent ) + : QDialog( parent ) + , ui( new Ui::ErrorDialog ) +{ + ui->setupUi( this ); + ui->iconLabel->setPixmap( QIcon::fromTheme( "dialog-error" ).pixmap( 64 ) ); + ui->detailsWidget->hide(); + ui->offerWebPasteLabel->hide(); +} + +ErrorDialog::~ErrorDialog() +{ + delete ui; +} + +QString +ErrorDialog::heading() const +{ + return ui->headingLabel->text(); +} + +QString +ErrorDialog::informativeText() const +{ + return ui->informativeTextLabel->text(); +} + +QString +ErrorDialog::details() const +{ + return ui->detailsBrowser->toPlainText(); +} + +void +ErrorDialog::setHeading( const QString& newHeading ) +{ + if ( ui->headingLabel->text() != newHeading ) + { + ui->headingLabel->setText( newHeading ); + emit headingChanged(); + } +} + +void +ErrorDialog::setInformativeText( const QString& newInformativeText ) +{ + if ( ui->informativeTextLabel->text() != newInformativeText ) + { + ui->informativeTextLabel->setText( newInformativeText ); + emit informativeTextChanged(); + } +} + +void +ErrorDialog::setDetails( const QString& newDetails ) +{ + if ( ui->detailsBrowser->toPlainText() != newDetails ) + { + ui->detailsBrowser->setPlainText( newDetails ); + ui->detailsWidget->setVisible( !ui->detailsBrowser->toPlainText().trimmed().isEmpty() ); + emit detailsChanged(); + } +} + +bool +ErrorDialog::shouldOfferWebPaste() const +{ + return m_shouldOfferWebPaste; +} + +void +ErrorDialog::setShouldOfferWebPaste( bool newShouldOfferWebPaste ) +{ + if ( m_shouldOfferWebPaste != newShouldOfferWebPaste ) + { + m_shouldOfferWebPaste = newShouldOfferWebPaste; + + ui->offerWebPasteLabel->setVisible( m_shouldOfferWebPaste ); + ui->buttonBox->setStandardButtons( m_shouldOfferWebPaste ? ( QDialogButtonBox::Yes | QDialogButtonBox::No ) + : QDialogButtonBox::Close ); + fixButtonLabels( ui->buttonBox ); + + emit shouldOfferWebPasteChanged(); + } +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/widgets/ErrorDialog.h b/calamares/src/libcalamaresui/widgets/ErrorDialog.h new file mode 100644 index 0000000..28ca70f --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/ErrorDialog.h @@ -0,0 +1,83 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Artem Grinev + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARESUI_ERRORDIALOG_H +#define LIBCALAMARESUI_ERRORDIALOG_H + +#include + +namespace Ui +{ +class ErrorDialog; +} // namespace Ui + +namespace Calamares +{ +class ErrorDialog : public QDialog +{ + Q_OBJECT + + Q_PROPERTY( QString heading READ heading WRITE setHeading NOTIFY headingChanged ) + Q_PROPERTY( QString informativeText READ informativeText WRITE setInformativeText NOTIFY informativeTextChanged ) + Q_PROPERTY( QString details READ details WRITE setDetails NOTIFY detailsChanged ) + Q_PROPERTY( bool shouldOfferWebPaste READ shouldOfferWebPaste WRITE setShouldOfferWebPaste NOTIFY + shouldOfferWebPasteChanged ) + +public: + explicit ErrorDialog( QWidget* parent = nullptr ); + ~ErrorDialog() override; + + /** @brief The heading (title) of the error dialog + * + * This is a short (one-line) title. It is human-readable, so should + * be translated at the time it is set. + */ + QString heading() const; + void setHeading( const QString& newHeading ); + + /** @brief The description of the problem + * + * Longer, human-readable, description of the problem. This text + * is word-wrapped as necessary. + */ + QString informativeText() const; + void setInformativeText( const QString& newInformativeText ); + + /** @brief Details of the problem + * + * This is generally command-output; it might not be translated + * when set. It should be considered "background to the informative + * text", or maybe "the reasons". Write the informative text for + * the end-user. + */ + QString details() const; + void setDetails( const QString& newDetails ); + + /** @brief Enable web-paste button + * + * The web-paste button can be configured at a global level, + * but each individual error dialog can be set separately. + */ + bool shouldOfferWebPaste() const; + void setShouldOfferWebPaste( bool newShouldOfferWebPaste ); + +signals: + void headingChanged(); + void informativeTextChanged(); + void detailsChanged(); + void shouldOfferWebPasteChanged(); + +private: + Ui::ErrorDialog* ui; + bool m_shouldOfferWebPaste = false; +}; + +} // namespace Calamares + +#endif // LIBCALAMARESUI_ERRORDIALOG_H diff --git a/calamares/src/libcalamaresui/widgets/ErrorDialog.ui b/calamares/src/libcalamaresui/widgets/ErrorDialog.ui new file mode 100644 index 0000000..edc9eb1 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/ErrorDialog.ui @@ -0,0 +1,137 @@ + + + +SPDX-FileCopyrightText: 2021 Artem Grinev <agrinev@manjaro.org> +SPDX-License-Identifier: GPL-3.0-or-later + + ErrorDialog + + + + 0 + 0 + 425 + 262 + + + + Dialog + + + + + + + + + + 0 + 0 + + + + + + + + + + + Details: + + + + + + + + + + + + + + + + Would you like to paste the install log to the web? + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + + 0 + 0 + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + + + buttonBox + accepted() + ErrorDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + ErrorDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.cpp b/calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.cpp new file mode 100644 index 0000000..cd9dc0d --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.cpp @@ -0,0 +1,39 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "FixedAspectRatioLabel.h" + + +FixedAspectRatioLabel::FixedAspectRatioLabel( QWidget* parent ) + : QLabel( parent ) +{ +} + + +FixedAspectRatioLabel::~FixedAspectRatioLabel() {} + + +void +FixedAspectRatioLabel::setPixmap( const QPixmap& pixmap ) +{ + m_pixmap = pixmap; + m_pixmap.setDevicePixelRatio( devicePixelRatio() ); + QLabel::setPixmap( m_pixmap.scaled( + contentsRect().size() * m_pixmap.devicePixelRatio(), Qt::KeepAspectRatio, Qt::SmoothTransformation ) ); +} + + +void +FixedAspectRatioLabel::resizeEvent( QResizeEvent* event ) +{ + Q_UNUSED( event ) + QLabel::setPixmap( m_pixmap.scaled( + contentsRect().size() * m_pixmap.devicePixelRatio(), Qt::KeepAspectRatio, Qt::SmoothTransformation ) ); +} diff --git a/calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.h b/calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.h new file mode 100644 index 0000000..58d0956 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/FixedAspectRatioLabel.h @@ -0,0 +1,34 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef FIXEDASPECTRATIOLABEL_H +#define FIXEDASPECTRATIOLABEL_H + +#include "DllMacro.h" + +#include +#include + +class UIDLLEXPORT FixedAspectRatioLabel : public QLabel +{ + Q_OBJECT +public: + explicit FixedAspectRatioLabel( QWidget* parent = nullptr ); + ~FixedAspectRatioLabel() override; + +public slots: + void setPixmap( const QPixmap& pixmap ); + void resizeEvent( QResizeEvent* event ) override; + +private: + QPixmap m_pixmap; +}; + +#endif // FIXEDASPECTRATIOLABEL_H diff --git a/calamares/src/libcalamaresui/widgets/LogWidget.cpp b/calamares/src/libcalamaresui/widgets/LogWidget.cpp new file mode 100644 index 0000000..108324a --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/LogWidget.cpp @@ -0,0 +1,112 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Bob van der Linden + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LogWidget.h" + +#include "utils/Logger.h" + +#include +#include +#include +#include +#include + +namespace Calamares +{ + +LogThread::LogThread( QObject* parent ) + : QThread( parent ) +{ +} + +LogThread::~LogThread() +{ + quit(); + requestInterruption(); + wait(); +} + +void +LogThread::run() +{ + const auto filePath = Logger::logFile(); + + qint64 lastPosition = 0; + + while ( !QThread::currentThread()->isInterruptionRequested() ) + { + QFile file( filePath ); + + qint64 fileSize = file.size(); + // Check whether the file size has changed since last time + // we read the file. + if ( lastPosition != fileSize && file.open( QFile::ReadOnly | QFile::Text ) ) + { + + // Start reading at the position we ended up last time we read the file. + file.seek( lastPosition ); + + QTextStream in( &file ); + auto chunk = in.readAll(); + qint64 newPosition = in.pos(); + + lastPosition = newPosition; + + Q_EMIT onLogChunk( chunk ); + } + QThread::msleep( 100 ); + } +} + +LogWidget::LogWidget( QWidget* parent ) + : QWidget( parent ) + , m_text( new QPlainTextEdit ) + , m_log_thread( this ) +{ + auto layout = new QStackedLayout( this ); + setLayout( layout ); + + m_text->setReadOnly( true ); + m_text->setVerticalScrollBarPolicy( Qt::ScrollBarPolicy::ScrollBarAlwaysOn ); + + QFont monospaceFont( "monospace" ); + monospaceFont.setStyleHint( QFont::Monospace ); + m_text->setFont( monospaceFont ); + + layout->addWidget( m_text ); + + connect( &m_log_thread, &LogThread::onLogChunk, this, &LogWidget::handleLogChunk ); + + m_log_thread.start( QThread::LowestPriority ); +} + +void +LogWidget::handleLogChunk( const QString& logChunk ) +{ + m_text->appendPlainText( logChunk ); +} + +void +LogWidget::start() +{ + if ( !m_log_thread.isRunning() ) + { + m_text->clear(); + m_log_thread.start(); + } +} + +void +LogWidget::stop() +{ + m_log_thread.requestInterruption(); +} + + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/widgets/LogWidget.h b/calamares/src/libcalamaresui/widgets/LogWidget.h new file mode 100644 index 0000000..5b3ef17 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/LogWidget.h @@ -0,0 +1,55 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Bob van der Linden + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARESUI_LOGWIDGET_H +#define LIBCALAMARESUI_LOGWIDGET_H + +#include +#include +#include + +namespace Calamares +{ + +class LogThread : public QThread +{ + Q_OBJECT + + void run() override; + +public: + explicit LogThread( QObject* parent = nullptr ); + ~LogThread() override; + +Q_SIGNALS: + void onLogChunk( const QString& logChunk ); +}; + +class LogWidget : public QWidget +{ + Q_OBJECT + + QPlainTextEdit* m_text; + LogThread m_log_thread; + +public: + explicit LogWidget( QWidget* parent = nullptr ); + +public Q_SLOTS: + /// @brief Called by the thread when there is new data + void handleLogChunk( const QString& logChunk ); + + /// @brief Stop watching for log data + void stop(); + /// @brief Start watching for new log data + void start(); +}; + +} // namespace Calamares +#endif // LOGWIDGET_H diff --git a/calamares/src/libcalamaresui/widgets/PrettyRadioButton.cpp b/calamares/src/libcalamaresui/widgets/PrettyRadioButton.cpp new file mode 100644 index 0000000..404d9c6 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/PrettyRadioButton.cpp @@ -0,0 +1,130 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PrettyRadioButton.h" + +#include "utils/Gui.h" +#include "widgets/ClickableLabel.h" + +#include +#include +#include +#include +#include + +namespace Calamares +{ +namespace Widgets +{ + +PrettyRadioButton::PrettyRadioButton( QWidget* parent ) + : QWidget( parent ) + , m_label( new ClickableLabel ) + , m_radio( new QRadioButton ) + , m_mainLayout( new QGridLayout ) + , m_optionsLayout( nullptr ) +{ + setLayout( m_mainLayout ); + + m_label->setBuddy( m_radio ); + + m_label->setWordWrap( true ); + m_label->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred ); + + m_mainLayout->addWidget( m_radio, 0, 0 ); + m_mainLayout->addWidget( m_label, 0, 1 ); + m_mainLayout->setContentsMargins( 0, 0, 0, 0 ); + + connect( m_label, &ClickableLabel::clicked, m_radio, &QRadioButton::click ); + connect( m_radio, &QRadioButton::toggled, this, &PrettyRadioButton::toggleOptions ); +} + + +void +PrettyRadioButton::setText( const QString& text ) +{ + m_label->setText( text ); +} + + +void +PrettyRadioButton::setIconSize( const QSize& size ) +{ + m_radio->setIconSize( size ); +} + + +void +PrettyRadioButton::setIcon( const QIcon& icon ) +{ + m_radio->setIcon( icon ); +} + + +QSize +PrettyRadioButton::iconSize() const +{ + return m_radio->iconSize(); +} + + +void +PrettyRadioButton::setChecked( bool checked ) +{ + m_radio->setChecked( checked ); +} + + +bool +PrettyRadioButton::isChecked() const +{ + return m_radio->isChecked(); +} + +void +PrettyRadioButton::addToGroup( QButtonGroup* group, int id ) +{ + group->addButton( m_radio, id ); +} + + +void +PrettyRadioButton::addOptionsComboBox( QComboBox* box ) +{ + if ( !box ) + { + return; + } + + if ( !m_optionsLayout ) + { + QWidget* w = new QWidget; + m_optionsLayout = new QHBoxLayout; + m_optionsLayout->setAlignment( Qt::AlignmentFlag::AlignLeft ); + m_optionsLayout->addStretch( 1 ); + + w->setLayout( m_optionsLayout ); + m_mainLayout->addWidget( w, 1, 1 ); + + toggleOptions( m_radio->isChecked() ); + } + + m_optionsLayout->insertWidget( m_optionsLayout->count() - 1, box ); +} + +void +PrettyRadioButton::toggleOptions( bool toggle ) +{ + if ( m_optionsLayout ) + { + m_optionsLayout->parentWidget()->setVisible( toggle ); + } +} +} // namespace Widgets +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/widgets/PrettyRadioButton.h b/calamares/src/libcalamaresui/widgets/PrettyRadioButton.h new file mode 100644 index 0000000..fd00911 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/PrettyRadioButton.h @@ -0,0 +1,79 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARESUI_WIDGETS_PRETTYRADIOBUTTON_H +#define LIBCALAMARESUI_WIDGETS_PRETTYRADIOBUTTON_H + +#include "DllMacro.h" + +#include + +class QButtonGroup; +class QComboBox; +class QGridLayout; +class QHBoxLayout; + +namespace Calamares +{ +namespace Widgets +{ +class ClickableLabel; + +/** @brief A radio button with fancy label next to it. + * + * The fancy label is used so that the text alongside the radio + * button can word-wrap, be multi-line, and support rich text. + * + * The radio button itself can be retrieved with buttonWidget(), + * and the whole behaves a lot like a label. Extra options can be + * added to the display (options are hidden when the button is + * not selected) with addOptionsComboBox(). + */ +class UIDLLEXPORT PrettyRadioButton : public QWidget +{ + Q_OBJECT +public: + explicit PrettyRadioButton( QWidget* parent = nullptr ); + ~PrettyRadioButton() override {} + + /// @brief Passes @p text on to the ClickableLabel + void setText( const QString& text ); + + // Icon applies to the radio-button part + void setIconSize( const QSize& size ); + QSize iconSize() const; + void setIcon( const QIcon& icon ); + + // Applies to the radio-button part + void setChecked( bool checked ); + bool isChecked() const; + + /** @brief Adds the radio-button part to the given @p group + * + * For managing the pretty-radio-button in button groups like normal + * radio buttons, call addToGroup() rather that group->addButton(). + */ + void addToGroup( QButtonGroup* group, int id = -1 ); + + /// @brief Add an options drop-down to this button. + void addOptionsComboBox( QComboBox* ); + +protected slots: + /// Options are hidden when the radio button is off + void toggleOptions( bool checked ); + +protected: + ClickableLabel* m_label; + QRadioButton* m_radio; + QGridLayout* m_mainLayout; + QHBoxLayout* m_optionsLayout; +}; +} // namespace Widgets +} // namespace Calamares +#endif // LIBCALAMARESUI_WIDGETS_PRETTYRADIOBUTTON_H diff --git a/calamares/src/libcalamaresui/widgets/TranslationFix.cpp b/calamares/src/libcalamaresui/widgets/TranslationFix.cpp new file mode 100644 index 0000000..dbfd0bd --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/TranslationFix.cpp @@ -0,0 +1,61 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "TranslationFix.h" + +#include +#include +#include +#include +#include + +namespace Calamares +{ + +//Using QMessageBox's StandardButton enum here but according to headers they should be kept in-sync between multiple classes. +static std::pair< decltype( QMessageBox::Ok ), const char* > maps[] = { + { QMessageBox::Ok, QT_TRANSLATE_NOOP( "StandardButtons", "&OK" ) }, + { QMessageBox::Yes, QT_TRANSLATE_NOOP( "StandardButtons", "&Yes" ) }, + { QMessageBox::No, QT_TRANSLATE_NOOP( "StandardButtons", "&No" ) }, + { QMessageBox::Cancel, QT_TRANSLATE_NOOP( "StandardButtons", "&Cancel" ) }, + { QMessageBox::Close, QT_TRANSLATE_NOOP( "StandardButtons", "&Close" ) }, +}; + +template < typename TButtonBox > +void +fixButtonLabels( TButtonBox* box ) +{ + if ( !box ) + { + return; + } + + for ( auto [ sb, label ] : maps ) + { + auto* button = box->button( static_cast< typename TButtonBox::StandardButton >( int( sb ) ) ); + if ( button ) + { + button->setText( QCoreApplication::translate( "StandardButtons", label ) ); + } + } +} + +void +fixButtonLabels( QMessageBox* box ) +{ + fixButtonLabels< QMessageBox >( box ); +} + +void +fixButtonLabels( QDialogButtonBox* box ) +{ + fixButtonLabels< QDialogButtonBox >( box ); +} + +} // namespace Calamares diff --git a/calamares/src/libcalamaresui/widgets/TranslationFix.h b/calamares/src/libcalamaresui/widgets/TranslationFix.h new file mode 100644 index 0000000..89ee9a5 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/TranslationFix.h @@ -0,0 +1,34 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LIBCALAMARESUI_WIDGETS_TRANSLATIONFIX_H +#define LIBCALAMARESUI_WIDGETS_TRANSLATIONFIX_H + +#include "DllMacro.h" + +class QMessageBox; +class QDialogButtonBox; + +namespace Calamares +{ + +/** @brief Fixes the labels on the standard buttons of the message box + * + * Updates OK / Cancel / Yes / No because there does not + * seem to be a way to do so in the Retranslator code + * (in libcalamares) since the translated strings may come + * from a variety of platform-plugin sources and we can't + * guess the context. + */ +void UIDLLEXPORT fixButtonLabels( QMessageBox* ); + +void UIDLLEXPORT fixButtonLabels( QDialogButtonBox* ); +} // namespace Calamares + +#endif diff --git a/calamares/src/libcalamaresui/widgets/WaitingWidget.cpp b/calamares/src/libcalamaresui/widgets/WaitingWidget.cpp new file mode 100644 index 0000000..f3616c9 --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/WaitingWidget.cpp @@ -0,0 +1,152 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "WaitingWidget.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" + +#include +#include +#include +#include + +static void +colorSpinner( WaitingSpinnerWidget* spinner ) +{ + const auto color = spinner->palette().text().color(); + spinner->setTextColor( color ); + spinner->setColor( color ); +} + +static void +styleSpinner( WaitingSpinnerWidget* spinner, int size ) +{ + spinner->setFixedSize( size, size ); + spinner->setInnerRadius( size / 2 ); + spinner->setLineLength( size / 2 ); + spinner->setLineWidth( size / 8 ); + colorSpinner( spinner ); +} + + +WaitingWidget::WaitingWidget( const QString& text, QWidget* parent ) + : WaitingSpinnerWidget( parent, false, false ) +{ + int spnrSize = Calamares::defaultFontHeight() * 4; + styleSpinner( this, spnrSize ); + setAlignment( Qt::AlignmentFlag::AlignBottom ); + setText( text ); + start(); +} + +WaitingWidget::~WaitingWidget() {} + +void +WaitingWidget::changeEvent( QEvent* event ) +{ + if ( event->type() == QEvent::PaletteChange ) + { + colorSpinner( this ); + } + WaitingSpinnerWidget::changeEvent( event ); +} + +struct CountdownWaitingWidget::Private +{ + std::chrono::seconds duration; + // int because we count down, need to be able to show a 0, + // and then wrap around to duration a second later. + int count = 0; + QTimer* timer = nullptr; + + Private( std::chrono::seconds seconds, QWidget* parent ) + : duration( seconds ) + , timer( new QTimer( parent ) ) + { + } +}; + +CountdownWaitingWidget::CountdownWaitingWidget( std::chrono::seconds duration, QWidget* parent ) + : WaitingSpinnerWidget( parent, false, false ) + , d( std::make_unique< Private >( duration, this ) ) +{ + // Set up the label first for sizing + const int labelHeight = qBound( 16, Calamares::defaultFontHeight() * 3 / 2, 64 ); + styleSpinner( this, labelHeight ); + setRevolutionsPerSecond( 1.0 / double( duration.count() ) ); + setAlignment( Qt::AlignmentFlag::AlignVCenter ); + + // Last because it updates the text + setInterval( duration ); + + d->timer->setInterval( std::chrono::seconds( 1 ) ); + connect( d->timer, &QTimer::timeout, this, &CountdownWaitingWidget::tick ); +} + +CountdownWaitingWidget::~CountdownWaitingWidget() +{ + d->timer->stop(); +} + +void +CountdownWaitingWidget::setInterval( std::chrono::seconds duration ) +{ + d->duration = duration; + d->count = int( duration.count() ); + tick(); +} + +void +CountdownWaitingWidget::start() +{ + // start it from the top + if ( d->count <= 0 ) + { + d->count = int( d->duration.count() ); + tick(); + } + d->timer->start(); + WaitingSpinnerWidget::start(); +} + +void +CountdownWaitingWidget::stop() +{ + d->timer->stop(); + WaitingSpinnerWidget::stop(); +} + +void +CountdownWaitingWidget::tick() +{ + // We do want to **display** a 0 which is why we wrap around only + // after counting down from 0. + d->count--; + if ( d->count < 0 ) + { + d->count = int( d->duration.count() ); + } + setText( QString::number( d->count ) ); + if ( d->count == 0 ) + { + timeout(); + } +} + +void +CountdownWaitingWidget::changeEvent( QEvent* event ) +{ + if ( event->type() == QEvent::PaletteChange ) + { + colorSpinner( this ); + } + WaitingSpinnerWidget::changeEvent( event ); +} diff --git a/calamares/src/libcalamaresui/widgets/WaitingWidget.h b/calamares/src/libcalamaresui/widgets/WaitingWidget.h new file mode 100644 index 0000000..312914e --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/WaitingWidget.h @@ -0,0 +1,78 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef WAITINGWIDGET_H +#define WAITINGWIDGET_H + +#include "DllMacro.h" + +#include "widgets/waitingspinnerwidget.h" + +#include +#include + +class QLabel; +class QTimer; + +/** @brief A spinner and a label below it + * + * The spinner has a fixed size of 4* the font height, + * and the text is displayed centered below it. Use this + * to display a long-term waiting situation with a status report. + */ +class UIDLLEXPORT WaitingWidget : public WaitingSpinnerWidget +{ +public: + /// Create a WaitingWidget with initial @p text label. + explicit WaitingWidget( const QString& text, QWidget* parent = nullptr ); + ~WaitingWidget() override; + +protected: + void changeEvent( QEvent* event ) override; +}; + +/** @brief A spinner and a countdown inside it + * + * The spinner is sized to the text-height and displays a + * numeric countdown iside the spinner. The countdown is updated + * every second. The signal timeout() is sent every time + * the countdown reaches 0. + */ +class UIDLLEXPORT CountdownWaitingWidget : public WaitingSpinnerWidget +{ + Q_OBJECT +public: + /// Create a countdown widget with a given @p duration + explicit CountdownWaitingWidget( std::chrono::seconds duration = std::chrono::seconds( 5 ), + QWidget* parent = nullptr ); + ~CountdownWaitingWidget() override; + + /// Changes the duration used and resets the countdown + void setInterval( std::chrono::seconds duration ); + + /// Start the countdown, resets to the full duration + void start(); + /// Stop the countdown + void stop(); + +Q_SIGNALS: + void timeout(); + +protected Q_SLOTS: + void tick(); + +protected: + void changeEvent( QEvent* event ) override; + +private: + struct Private; + std::unique_ptr< Private > d; +}; + +#endif // WAITINGWIDGET_H diff --git a/calamares/src/libcalamaresui/widgets/waitingspinnerwidget.cpp b/calamares/src/libcalamaresui/widgets/waitingspinnerwidget.cpp new file mode 100644 index 0000000..2ee5a5e --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/waitingspinnerwidget.cpp @@ -0,0 +1,390 @@ +/* + * SPDX-FileCopyrightText: 2012-2014 Alexander Turkin + * SPDX-FileCopyrightText: 2014 William Hallatt + * SPDX-FileCopyrightText: 2015 Jacob Dawid + * SPDX-FileCopyrightText: 2018 huxingyi + * SPDX-License-Identifier: MIT + */ + +/* Original Work Copyright (c) 2012-2014 Alexander Turkin + Modified 2014 by William Hallatt + Modified 2015 by Jacob Dawid + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +#include "waitingspinnerwidget.h" + +#include +#include +#include + +static bool +isAlignCenter( Qt::AlignmentFlag a ) +{ + return a == Qt::AlignmentFlag::AlignVCenter; +} + +static int +lineCountDistanceFromPrimary( int current, int primary, int totalNrOfLines ) +{ + int distance = primary - current; + if ( distance < 0 ) + { + distance += totalNrOfLines; + } + return distance; +} + +static QColor +currentLineColor( int countDistance, int totalNrOfLines, qreal trailFadePerc, qreal minOpacity, QColor color ) +{ + if ( countDistance == 0 ) + { + return color; + } + const qreal minAlphaF = minOpacity / 100.0; + int distanceThreshold = static_cast< int >( qCeil( ( totalNrOfLines - 1 ) * trailFadePerc / 100.0 ) ); + if ( countDistance > distanceThreshold ) + { + color.setAlphaF( minAlphaF ); + } + else + { + qreal alphaDiff = color.alphaF() - minAlphaF; + qreal gradient = alphaDiff / static_cast< qreal >( distanceThreshold + 1 ); + qreal resultAlpha = color.alphaF() - gradient * countDistance; + + // If alpha is out of bounds, clip it. + resultAlpha = qBound( 0.0, resultAlpha, 1.0 ); + color.setAlphaF( resultAlpha ); + } + return color; +} + +WaitingSpinnerWidget::WaitingSpinnerWidget( QWidget* parent, bool centerOnParent, bool disableParentWhenSpinning ) + : WaitingSpinnerWidget( Qt::WindowModality::NonModal, parent, centerOnParent, disableParentWhenSpinning ) +{ +} + +WaitingSpinnerWidget::WaitingSpinnerWidget( Qt::WindowModality modality, + QWidget* parent, + bool centerOnParent, + bool disableParentWhenSpinning ) + : QWidget( parent, + modality == Qt::WindowModality::NonModal ? Qt::WindowFlags() : Qt::Dialog | Qt::FramelessWindowHint ) + , _centerOnParent( centerOnParent ) + , _disableParentWhenSpinning( disableParentWhenSpinning ) +{ + _timer = new QTimer( this ); + connect( _timer, SIGNAL( timeout() ), this, SLOT( rotate() ) ); + updateSize(); + updateTimer(); + hide(); + + // We need to set the window modality AFTER we've hidden the + // widget for the first time since changing this property while + // the widget is visible has no effect. + // + // Non-modal windows don't need any work + if ( modality != Qt::WindowModality::NonModal ) + { + setWindowModality( modality ); + setAttribute( Qt::WA_TranslucentBackground ); + } +} + +void +WaitingSpinnerWidget::paintEvent( QPaintEvent* ) +{ + updatePosition(); + QPainter painter( this ); + painter.fillRect( this->rect(), Qt::transparent ); + painter.setRenderHint( QPainter::Antialiasing, true ); + + if ( _currentCounter >= _numberOfLines ) + { + _currentCounter = 0; + } + + painter.setPen( Qt::NoPen ); + for ( int i = 0; i < _numberOfLines; ++i ) + { + painter.save(); + painter.translate( _innerRadius + _lineLength, _innerRadius + _lineLength ); + painter.translate( ( width() - _imageSize.width() ) / 2, 0 ); + qreal rotateAngle = static_cast< qreal >( 360 * i ) / static_cast< qreal >( _numberOfLines ); + painter.rotate( rotateAngle ); + painter.translate( _innerRadius, 0 ); + int distance = lineCountDistanceFromPrimary( i, _currentCounter, _numberOfLines ); + QColor color = currentLineColor( distance, _numberOfLines, _trailFadePercentage, _minimumTrailOpacity, _color ); + painter.setBrush( color ); + // TODO improve the way rounded rect is painted + painter.drawRoundedRect( + QRect( 0, -_lineWidth / 2, _lineLength, _lineWidth ), _roundness, _roundness, Qt::RelativeSize ); + painter.restore(); + } + + if ( !_text.isEmpty() ) + { + painter.setPen( QPen( _textColor ) ); + if ( isAlignCenter( alignment() ) ) + { + painter.drawText( QRect( 0, 0, width(), height() ), Qt::AlignVCenter | Qt::AlignHCenter, _text ); + } + else + { + painter.drawText( QRect( 0, _imageSize.height(), width(), height() - _imageSize.height() ), + Qt::AlignBottom | Qt::AlignHCenter, + _text ); + } + } +} + +void +WaitingSpinnerWidget::start() +{ + updatePosition(); + _isSpinning = true; + show(); + + if ( parentWidget() && _disableParentWhenSpinning ) + { + parentWidget()->setEnabled( false ); + } + + if ( !_timer->isActive() ) + { + _timer->start(); + _currentCounter = 0; + } +} + +void +WaitingSpinnerWidget::stop() +{ + _isSpinning = false; + hide(); + + if ( parentWidget() && _disableParentWhenSpinning ) + { + parentWidget()->setEnabled( true ); + } + + if ( _timer->isActive() ) + { + _timer->stop(); + _currentCounter = 0; + } +} + +void +WaitingSpinnerWidget::setNumberOfLines( int lines ) +{ + _numberOfLines = lines; + _currentCounter = 0; + updateTimer(); +} + +void +WaitingSpinnerWidget::setLineLength( int length ) +{ + _lineLength = length; + updateSize(); +} + +void +WaitingSpinnerWidget::setLineWidth( int width ) +{ + _lineWidth = width; + updateSize(); +} + +void +WaitingSpinnerWidget::setInnerRadius( int radius ) +{ + _innerRadius = radius; + updateSize(); +} + +void +WaitingSpinnerWidget::setText( const QString& text ) +{ + _text = text; + updateSize(); +} + +void +WaitingSpinnerWidget::setAlignment( Qt::AlignmentFlag align ) +{ + _alignment = align; + updateSize(); +} + +QColor +WaitingSpinnerWidget::color() const +{ + return _color; +} + +QColor +WaitingSpinnerWidget::textColor() const +{ + return _textColor; +} + +QString +WaitingSpinnerWidget::text() const +{ + return _text; +} + +qreal +WaitingSpinnerWidget::roundness() const +{ + return _roundness; +} + +qreal +WaitingSpinnerWidget::minimumTrailOpacity() const +{ + return _minimumTrailOpacity; +} + +qreal +WaitingSpinnerWidget::trailFadePercentage() const +{ + return _trailFadePercentage; +} + +qreal +WaitingSpinnerWidget::revolutionsPersSecond() const +{ + return _revolutionsPerSecond; +} + +int +WaitingSpinnerWidget::numberOfLines() const +{ + return _numberOfLines; +} + +int +WaitingSpinnerWidget::lineLength() const +{ + return _lineLength; +} + +int +WaitingSpinnerWidget::lineWidth() const +{ + return _lineWidth; +} + +int +WaitingSpinnerWidget::innerRadius() const +{ + return _innerRadius; +} + +bool +WaitingSpinnerWidget::isSpinning() const +{ + return _isSpinning; +} + +void +WaitingSpinnerWidget::setRoundness( qreal roundness ) +{ + _roundness = qBound( 0.0, roundness, 100.0 ); +} + +void +WaitingSpinnerWidget::setColor( QColor color ) +{ + _color = color; +} + +void +WaitingSpinnerWidget::setTextColor( QColor color ) +{ + _textColor = color; +} + +void +WaitingSpinnerWidget::setRevolutionsPerSecond( qreal revolutionsPerSecond ) +{ + _revolutionsPerSecond = revolutionsPerSecond; + updateTimer(); +} + +void +WaitingSpinnerWidget::setTrailFadePercentage( qreal trail ) +{ + _trailFadePercentage = trail; +} + +void +WaitingSpinnerWidget::setMinimumTrailOpacity( qreal minimumTrailOpacity ) +{ + _minimumTrailOpacity = minimumTrailOpacity; +} + +void +WaitingSpinnerWidget::rotate() +{ + ++_currentCounter; + if ( _currentCounter >= _numberOfLines ) + { + _currentCounter = 0; + } + update(); +} + +void +WaitingSpinnerWidget::updateSize() +{ + int size = ( _innerRadius + _lineLength ) * 2; + _imageSize = QSize( size, size ); + if ( _text.isEmpty() || isAlignCenter( alignment() ) ) + { + setFixedSize( size, size ); + } + else + { + QFontMetrics fm( font() ); + QSize textSize = QSize( fm.horizontalAdvance( _text ), fm.height() ); + setFixedSize( qMax( size, textSize.width() ), size + size / 4 + textSize.height() ); + } +} + +void +WaitingSpinnerWidget::updateTimer() +{ + // Old-style interval in milliseconds; force to int to suppress warning + _timer->setInterval( int( 1000 / ( _numberOfLines * _revolutionsPerSecond ) ) ); +} + +void +WaitingSpinnerWidget::updatePosition() +{ + if ( parentWidget() && _centerOnParent ) + { + move( parentWidget()->width() / 2 - width() / 2, parentWidget()->height() / 2 - height() / 2 ); + } +} diff --git a/calamares/src/libcalamaresui/widgets/waitingspinnerwidget.h b/calamares/src/libcalamaresui/widgets/waitingspinnerwidget.h new file mode 100644 index 0000000..29f98af --- /dev/null +++ b/calamares/src/libcalamaresui/widgets/waitingspinnerwidget.h @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: 2012-2014 Alexander Turkin + * SPDX-FileCopyrightText: 2014 William Hallatt + * SPDX-FileCopyrightText: 2015 Jacob Dawid + * SPDX-FileCopyrightText: 2018 huxingyi + * SPDX-License-Identifier: MIT + */ + +/* Original Work Copyright (c) 2012-2014 Alexander Turkin + Modified 2014 by William Hallatt + Modified 2015 by Jacob Dawid + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +#pragma once + +#include "DllMacro.h" + +#include +#include +#include + +class UIDLLEXPORT WaitingSpinnerWidget : public QWidget +{ + Q_OBJECT +public: + /** @brief Constructor for "standard" widget behaviour + * + * Use this constructor if you wish to, e.g. embed your widget in another. + */ + WaitingSpinnerWidget( QWidget* parent = nullptr, + bool centerOnParent = true, + bool disableParentWhenSpinning = true ); + + /** @brief Constructor + * + * Use this constructor to automatically create a modal + * ("blocking") spinner on top of the calling widget/window. If a valid + * parent widget is provided, "centreOnParent" will ensure that + * QtWaitingSpinner automatically centres itself on it, if not, + * @p centerOnParent is ignored. + */ + WaitingSpinnerWidget( Qt::WindowModality modality, + QWidget* parent = nullptr, + bool centerOnParent = true, + bool disableParentWhenSpinning = true ); + + WaitingSpinnerWidget( const WaitingSpinnerWidget& ) = delete; + WaitingSpinnerWidget& operator=( const WaitingSpinnerWidget& ) = delete; + + void setColor( QColor color ); + void setTextColor( QColor color ); + void setRoundness( qreal roundness ); + void setMinimumTrailOpacity( qreal minimumTrailOpacity ); + void setTrailFadePercentage( qreal trail ); + void setRevolutionsPerSecond( qreal revolutionsPerSecond ); + void setNumberOfLines( int lines ); + void setLineLength( int length ); + void setLineWidth( int width ); + void setInnerRadius( int radius ); + + /** @brief Sets the text displayed in or below the spinner + * + * If the text is empty, no text is displayed. The text is displayed + * in or below the spinner depending on the value of alignment(). + * With AlignBottom, the text is displayed below the spinner, + * centered horizontally relative to the spinner; any other alignment + * will put the text in the middle of the spinner itself. + * + * TODO: this does not support rich text. Rich text could be done + * through a QStaticText, or an HTML document. However, then + * we need to do more alignment calculations ourselves. + */ + void setText( const QString& text ); + /** @brief Sets the alignment of text for the spinner + * + * The only meaningful values are AlignBottom and AlignVCenter, + * for text below the spinner and text in the middle. + */ + void setAlignment( Qt::AlignmentFlag align ); + /// Convenience to set text-in-the-middle (@c true) or text-at-bottom (@c false) + void setCenteredText( bool centered ) + { + setAlignment( centered ? Qt::AlignmentFlag::AlignVCenter : Qt::AlignmentFlag::AlignBottom ); + } + + QColor color() const; + QColor textColor() const; + QString text() const; + Qt::AlignmentFlag alignment() const { return _alignment; } + qreal roundness() const; + qreal minimumTrailOpacity() const; + qreal trailFadePercentage() const; + qreal revolutionsPersSecond() const; + int numberOfLines() const; + int lineLength() const; + int lineWidth() const; + int innerRadius() const; + + bool isSpinning() const; + +public Q_SLOTS: + void start(); + void stop(); + +private Q_SLOTS: + void rotate(); + +protected: + void paintEvent( QPaintEvent* paintEvent ) override; + +private: + void updateSize(); + void updateTimer(); + void updatePosition(); + +private: + // PI, leading to a full fade in one whole revolution + static constexpr const auto radian = 3.14159265358979323846; + + // Spinner-wheel related settings + QColor _color = Qt::black; + qreal _roundness = 100.0; // 0..100 + qreal _minimumTrailOpacity = radian; + qreal _trailFadePercentage = 80.0; + qreal _revolutionsPerSecond = radian / 2; + int _numberOfLines = 20; + int _lineLength = 10; + int _lineWidth = 2; + int _innerRadius = 10; + QSize _imageSize; + + // Text-related settings + Qt::AlignmentFlag _alignment = Qt::AlignmentFlag::AlignBottom; + QString _text; + QColor _textColor = Qt::black; + + // Environment settings + bool _centerOnParent = true; + bool _disableParentWhenSpinning = true; + + // Internal bits + QTimer* _timer = nullptr; + int _currentCounter = 0; + bool _isSpinning = false; +}; diff --git a/calamares/src/modules/CMakeLists.txt b/calamares/src/modules/CMakeLists.txt new file mode 100644 index 0000000..8807da8 --- /dev/null +++ b/calamares/src/modules/CMakeLists.txt @@ -0,0 +1,63 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# The variable SKIP_MODULES can be set to skip particular modules; +# individual modules can also decide they must be skipped (e.g. OS-specific +# modules, or ones with unmet dependencies). Collect the skipped modules +# in this list. +set(LIST_SKIPPED_MODULES "") + +include_directories( + ${CMAKE_SOURCE_DIR}/src/libcalamares + ${CMAKE_BINARY_DIR}/src/libcalamares + ${CMAKE_SOURCE_DIR}/src/libcalamaresui +) + +string(REPLACE " " ";" SKIP_LIST "${SKIP_MODULES}") + +file(GLOB SUBDIRECTORIES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "*") +list(SORT SUBDIRECTORIES) +list(FILTER SUBDIRECTORIES EXCLUDE REGEX CMakeLists.txt) +list(FILTER SUBDIRECTORIES EXCLUDE REGEX README.md) + +foreach(SUBDIRECTORY ${SUBDIRECTORIES}) + calamares_add_module_subdirectory( ${SUBDIRECTORY} LIST_SKIPPED_MODULES ) +endforeach() + +if(BUILD_TESTING AND BUILD_SCHEMA_TESTING AND Python_Interpreter_FOUND) + # The tests for each config file are independent of whether the + # module is enabled or not: the config file should match its schema + # regardless. + foreach(SUBDIRECTORY ${SUBDIRECTORIES}) + set(_schema_file "${CMAKE_CURRENT_SOURCE_DIR}/${SUBDIRECTORY}/${SUBDIRECTORY}.schema.yaml") + # Collect config files from the module-directory and from a tests/ subdir, + # using the same mechanism to find those test-config-files as function + # calamares_add_module_subdirectory() would do. + set(_conf_files "${CMAKE_CURRENT_SOURCE_DIR}/${SUBDIRECTORY}/${SUBDIRECTORY}.conf") + set(_count 1) + set(_testdir "${CMAKE_CURRENT_SOURCE_DIR}/${SUBDIRECTORY}/tests") + while(EXISTS "${_testdir}/${_count}.global" OR EXISTS "${_testdir}/${_count}.job") + if(EXISTS "${_testdir}/${_count}.job") + list(APPEND _conf_files "${_testdir}/${_count}.job") + endif() + math(EXPR _count "${_count} + 1") + endwhile() + + if(EXISTS "${_schema_file}") + foreach(_conf_file ${_conf_files}) + if(EXISTS ${_conf_file}) + get_filename_component(_conf_base "${_conf_file}" NAME_WE) + add_test( + NAME validate-${SUBDIRECTORY}-${_conf_base} + COMMAND + ${Python_EXECUTABLE} "${CMAKE_SOURCE_DIR}/ci/configvalidator.py" "${_schema_file}" + "${_conf_file}" + ) + endif() + endforeach() + endif() + endforeach() +endif() diff --git a/calamares/src/modules/README.md b/calamares/src/modules/README.md new file mode 100644 index 0000000..e4e5678 --- /dev/null +++ b/calamares/src/modules/README.md @@ -0,0 +1,546 @@ +# Calamares modules + + + +Calamares modules are plugins that provide features like installer pages, +batch jobs, etc. An installer page (visible to the user) is called a "view", +while other modules are "jobs". + +Each Calamares module lives in its own directory. + +All modules are installed in `$DESTDIR/lib/calamares/modules`. + +There are two **types** of Calamares module: +* viewmodule, for user-visible modules. These use C++ and either Widgets or QML +* jobmodule, for not-user-visible modules. These may be done in C++, + Python, or as external processes (external processes not recommended). + +A viewmodule exposes a UI to the user. + +There are three **interfaces** for Calamares modules: +* qtplugin (viewmodules, jobmodules), +* python (jobmodules only), +* process (jobmodules only, not recommended). + +## Module directory + +Each Calamares module lives in its own directory. The contents +of the directory depend on the interface and type of the module. + +### Module descriptor + +A Calamares module must have a *module descriptor file*, named +`module.desc`. For C++ (qtplugin) modules using CMake as a build- +system and using the calamares_add_plugin() function -- this is the +recommended way to create such modules -- the module descriptor +file is optional, since it can be generated by the build system. +For other module interfaces, the module descriptor file is required. + +The module descriptor file, if required, is placed in the module's directory. +The module descriptor file is a YAML 1.2 document which defines the +module's name, type, interface and possibly other properties. The name +of the module as defined in `module.desc` must be the same as the name +of the module's directory. + +Module descriptors **must** have the following keys: +- *name* (an identifier; must be the same as the directory name) +- *type* ("job" or "view") +- *interface* (see below for the different interfaces; generally we + refer to the kinds of modules by their interface) + +Module descriptors for C++ modules **may** have the following key: +- *load* (the name of the shared library to load; if empty, uses a + standard library name derived from the module name) + +Module descriptors for Python modules **must** have the following key: +- *script* (the name of the Python script to load, nearly always `main.py`) + +Module descriptors for process modules **must** have the following key: +- *command* (the command to run) + +Module descriptors for process modules **may** have the following keys: +- *timeout* (how long, in seconds, to wait for the command to run) +- *chroot* (if true, run the command in the target system rather than the host) +Note that process modules are not recommended. + +Module descriptors **may** have the following keys: +- *emergency* (a boolean value, set to true to mark the module + as an emergency module; see the section *Emergency Modules*, below) +- *noconfig* (a boolean value, set to true to state that the module + has no configuration file; defaults to false) +- *requiredModules* (a list of modules which are required for this module + to operate properly) +- *weight* (a relative module weight, used to scale progress reporting) + + +### Required Modules + +A module may list zero (if it has no requirements) or more modules +by name. As modules are loaded from the global sequence in `settings.conf`, +each module is checked that all of the modules it requires are +already loaded before it. This ensures that if a module needs +another one to fill in globalstorage keys, that happens before +it needs those keys. + +### Emergency Modules + +If, during an *exec* step in the sequence, a module fails, installation as +a whole fails and the install is aborted. If there are emergency modules +in the **same** exec block, those will be executed before the installation +is aborted. Non-emergency modules are not executed. + +If an emergency-module fails while processing emergency-modules for +another failed module, that failure is ignored and emergency-module +processing continues. + +Use the EMERGENCY keyword in the CMake description of a C++ module +to generate a suitable `module.desc`. For Python modules, manually add +`emergency: true` to `module.desc`. + +A module that is marked as an emergency module in its module.desc +must **also** set the *emergency* key to *true* in its configuration file +(see below). If it does not, the module is not considered to be an emergency +module after all. This is so that you can have modules that have several +instances, only some of which are actually needed for emergencies. + +In summary: +- in `module.desc`, write `emergency: true` to make it **possible** to + run the module in emergency mode, +- in `.conf`, write `emergency: true` to make that specific + module run in emergency mode. + +### Module-specific configuration + +A Calamares module **may** read a module configuration file, +named `.conf`. If such a file is present in the +module's directory, it can be shipped as a *default* configuration file. +This only happens if the CMake-time option `INSTALL_CONFIG` is on. + +The name of the configuration file for a given module can be +influenced by the `settings.conf` of the overall Calamares configuration. +By default, though, the module's own name is used. + +Modules that have *noconfig* set to true will not attempt to +read a configuration file, and will not warn that one is missing; +conversely if *noconfig* is set to false (or is missing, since +the default value is false) if there is no configuration file, +a warning is printed during Calamares start-up. + +The sample configuration files may work and may be suitable for +your distribution, but no guarantee is given about their stability +beyond syntactic correctness. + +The module configuration file, if it exists, is a YAML 1.2 document +which contains a YAML map of anything. + +All sample module configuration files are installed in +`$DESTDIR/share/calamares/modules` but can be overridden by +files with the same name placed manually (or by the packager) +in `/etc/calamares/modules`. + +### Module Weights + +During the *exec* phase of an installation, where jobs are run and +things happen to the target system, there is a running progress bar. +It goes from 0% to 100% while all of the jobs for that exec phase +are run. Generally, one module creates one job, but this varies a little +(e.g. the partition module can spawn a whole bunch of jobs to +deal with each disk, and the users module has separate jobs for +the regular user and the root user). + +By default, modules all "weigh" the same, and each job is equal. +A typical installation has about 30 modules in the exec phase, +so there may be 40 jobs or so: each job represents 2.5% of the +overall progress of the installation. + +The consequence is that the *unpackfs* module, which needs to write +a few hundred MB to disk, gets 2.5% of the progress, and the *machineid* +module, which is essentially instantaneous, also gets 2.5% of the progress. +This makes progress reporting seem weird and uneven, and suggests to users +that Calamares may be "hanging" during the unpackfs stage. + +A module may be assigned a different "weight" in the `module.desc` +file (or via the CMake macros for adding plugins). This gives the +module more space in the overall progress: for instance, the *unpackfs* +module now has a weight of 12, so (assuming there are 38 modules +in the exec phase with a weight of 1, and *unpackfs* with a weight of 12) +regular modules get 2% (1 in 50 total weight) of the overall progress +bar, and the *unpackfs* module gets 24% (12 in 50). While this doesn't +speed anything up, it does make the progress in the unpackfs module more +visible. + +It is also possible to set a weight on a specific module **instance**, +which can be done in `settings.conf`. This overrides any weight +set in the module descriptor. Doing so is the recommended approach, +since that is where the specific installation-process is configured; +it is possible to take the whole installation-process into account +for determining the relative weights there. + + +## Global Storage keys + +Some modules place values in Global Storage so that they can be referenced later by other modules or even other parts of the same module. The following table represents a partial list of the values available as well as where they originate from and which module consume them. +Keys whose name is followed by a `+` are **structured** data, and have +entries (which start with `+`) below the parent key describing subkeys. +Some structured keys refer to other documentation sources. + +Key |Source |Consumers |Description +------------------|----------------|---------------|--- +bootloader + |partition | |Bootloader location +\+ installPath | | |Device (e.g. `/dev/sda`) where the bootloader is installed +branding + | | |See `src/branding/README.md` +btrfsSubvolumes |mount |fstab |List of maps containing the mountpoint and btrtfs subvolume +btrfsRootSubvolume|mount |bootloader, luksopenswaphook|String containing the subvolume mounted at root +efiSystemPartition|partition |bootloader, fstab|String containing the path to the ESP relative to the installed system +extraMounts |mount |unpackfs|List of maps holding metadata for the temporary mountpoints used by the installer +fullname |users | |The full username (e.g. "Jane Q. Public") +hostname |users | |A string containing the hostname of the new system +luksPassphrase |partition | |Obfuscated passphrase used on luks partition +netinstallAdd |packagechooser |netinstall |Data to add to netinstall tree. Same format as netinstall.yaml +netinstallSelect |packagechooser |netinstall |List of group names to select in the netinstall tree +packageOperations +|packagechooser, netinstall|packages|Operations to perform +\+ (list data) | | |See `packages.conf` +partitions + |partition, rawfs|(many) |List of maps of metadata about each partition +\+ device | | |path to the partition device +\+ fs | | |the name of the file system +\+ mountPoint | | |where the device should be mounted +\+ uuid | | |the UUID of the partition device +rootMountPoint |mount |(many) |A string with the absolute path to the root mountpoint +username |users |networkcfg, plasmainf, preservefiles|A string containing the username of the new user +zfsDatasets |zfs |bootloader, grubcfg, mount|List of maps of zfs datasets including the name and mount information +zfsInfo |partition |mount, zfs |List of encrypted zfs partitions and the encription info +zfsPoolInfo |zfs |mount, umount |List of maps of zfs pool info including the name and mountpoint + + + + +## C++ modules + +> Type: viewmodule, jobmodule +> Interface: qtplugin + +Currently the recommended way to write a module which exposes one or more +installer pages (viewmodule) is through a C++ and Qt plugin. Viewmodules must +implement `Calamares::ViewStep`. They can also implement `Calamares::Job` +to provide jobs. + +To add a Qt plugin module, put it in a subdirectory and make sure it has +a `CMakeLists.txt` with a `calamares_add_plugin` call. It will be picked +up automatically by our CMake magic. The `module.desc` file is not recommended: +nearly all cases can be described in CMake. + +Modules can be tested with the `loadmodule` testing executable in +the build directory. See the section on [testing modules](#testing-modules) +for more details. + + +### C++ Jobmodule + +**TODO:** this needs documentation + +### C++ Widgets Viewmodule + +**TODO:** this needs documentation + +### C++ QML Viewmodule + +A QML Viewmodule (or view step) puts much of the UI work in one or more +QML files; the files may be loaded from the branding directory or compiled +into the module. Which QML is used depends on the deployment and the +configuration files for Calamares. + +#### Explicit properties + +The QML can access data from the C++ framework though properties +exposed to QML. There are two libraries that need to be imported +explicitly: + +``` +import io.calamares.core 1.0 +import io.calamares.ui 1.0 +``` + +The *ui* library contains the *Branding* object, which corresponds to +the branding information set through `branding.desc`. The Branding +class (in `src/libcalamaresui/Branding.h` offers a QObject-property +based API, where the most important functions are `string()` and the +convenience functions `versionedName()` and similar. + +The *core* library contains both *ViewManager*, which handles overall +progress through the application, and *Global*, which holds global +storage information. Both objects have an extensive API. The *ViewManager* +can behave as a model for list views and the like. + +These explicit properties from libraries are shared across all the +QML modules (for global storage that goes without saying: it is +the mechanism to share information with other modules). + +#### Implicit properties + +Each module also has an implicit context property available to it. +No import is needed. The context property *config* (note lower case) +holds the Config object for the module. + +The Config object is the bridge between C++ and QML. + +A Config object must inherit QObject and should expose, as `Q_PROPERTY`, +all of the relevant configuration information for the module instance. +The general description how to do that is available +in the [Qt documentation](https://doc.qt.io/qt-5/qtqml-cppintegration-topic.html). + + +## Python modules + +Modules may use one of the python interfaces, which may be present +in a Calamares installation (but also may not be). These modules must have +a `module.desc` file. The Python script must implement the +Python jobmodule interface. + +To add a Python or process jobmodule, put it in a subdirectory and make sure +it has a `module.desc`. It will be picked up automatically by our CMake magic. +For all kinds of Python jobs, the key *script* must be set to the name of +the main python file for the job. This is almost universally `main.py`. + +`CMakeLists.txt` is *not* used for Python jobmodules. + +Calamares offers a Python API for module developers, the core Calamares +functionality is exposed as `libcalamares.job` for job data, +`libcalamares.globalstorage` for shared data and `libcalamares.utils` for +generic utility functions. Documentation is inline. + +All code in Python job modules must obey PEP8, the only exception are +`libcalamares.globalstorage` keys, which should always be +camelCaseWithLowerCaseInitial to match the C++ identifier convention. + +Modules can be tested with the `loadmodule` testing executable in +the build directory. See the section on [testing modules](#testing-modules) +for more details. + + +### Python Jobmodule + +> Type: jobmodule +> Interface: python + +A Python jobmodule is a Python program which imports libcalamares and has a +function `run()` as entry point. The function `run()` must return `None` if +everything went well, or a tuple `(str,str)` with an error message and +description if something went wrong. + +### Python API + +The interface from a Python module to Calamares internals is +found in the *libcalamares* module. This is not a standard Python +module, and is only available inside the Calamares "runtime" for +Python modules (it is implemented in C++ and injected into the Python +environment by Calamares). + +A module should start by importing the Calamares internals: + +``` +import libcalamares +``` + +There are three important (sub)modules in *libcalamares*: +- *globalstorage* behaves like a dictionary, and interfaces + with the global storage in Calamares; use it to transfer + information between modules (e.g. the *partition* module + shares the partition layout it creates). Note that some information + in global storage is expected to be structured, and it may be + dicts-within-dicts. + + An example of using globalstorage: + ``` + if not libcalamares.globalstorage.contains("lala"): + libcalamares.globalstorage.insert("lala", 72) + ``` +- *job* is the interface to the job's behavior, with one important + data member: *configuration* which is a dictionary derived from the + configuration file for the module (if there is one, empty otherwise). + Less important data is *pretty_name* (a string) and *working_path* + which are normally not needed. The *pretty_name* value is + obtained by the Calamares internals by calling the `pretty_name()` + function inside the Python module. + + There is one function: `setprogress(p)` which can be passed a float + *p* between 0 and 1 to indicate 0% to 100% completion of the module's + work. +- *utils* is where non-job-specific functions are placed: + - `debug(s)` and `warning(s)` are logger functions, which send output + to the usual Calamares logging functions. Use these over `print()` + which may not be visible at all. + - `mount(device, path, type, options)` mounts a filesystem from + *device* onto *path*, as if running the mount command from the shell. + Use this in preference to running mount by hand. In Calamares 3.3 + this function also handles privilege escalation. + - `gettext_path()` and `gettext_languages()` are support functions + for translations, which would normally be called only once when + setting up gettext (see below). + - `obscure(s)` is a lousy string obfuscation mechanism. Do not use it. + - A half-dozen functions for running a command and dealing with its + output. These are recommended over using `os.system()` or the *subprocess* + module because they handle the chroot behavior for running in the + target system transparently. In Calamares 3.3 these functions also + handle privilege escalation. See below, *Running Commands in Python* for details. + +A module **must** contain a `run()` function to do the actual work +of the module. The module **may** define the following functions +to provide information to Calamares: +- `pretty_name()` returns a string that is a human-readable name or + short description of the module. Since it is human-readable, + return a translated string. +- `pretty_status_message()` returns a (longer) string that is a human-readable + description of the state of the module, or what it is doing. This is + primarily of importance for long-running modules. The function is called + by the Calamares framework when the module reports progress through the + `job.setprogress()` function. Since the status is human-readable, + return a translated string. + +### Python Translations + +Translations in Python modules -- at least the ones in the Calamares core +repository -- are handled through gettext. You should import the standard +Python *gettext* module. Conventionally, `_` is used to mark translations. +That function needs to be configured specifically for use in Calamares +so that it can find the translations. A boilerplate solution is this: + +``` +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext +``` + +Error messages should be logged in English, and given to the user +in translated form. In particular, when returning an error message +and description from the `run()` function, return translated forms, +like the following: + +``` +return ( + _("No configuration found"), + _("")) +``` + +### Running Commands in Python + +The use of the `os.system()` function and *subprocess* modules is +discouraged. Using these makes the caller responsible for handling +any chroot or other target-versus-host-system manipulation, and in +Calamares 3.3 may require additional privilege escalation handling. + +The primary functions for running a command from Python are: +- `target_env_process_output(command, callback, stdin, timeout)` +- `host_env_process_output(command, callback, stdin, timeout)` +They run the given *command* (which must be a list of strings, like +`sys.argv` or what would be passed to a *subprocess* module call) +either in the target system (within the chroot) or in the host system. +Except for *command*, the arguments are optional. + +A very simple example is running `ls` from a Python module (with `libcalamares.utils.` qualification omitted): +``` +target_env_process_output(["ls"]) +``` + +The functions return 0. If the exit code of *command* is not 0, an exception +is raised instead of returning 0. The exception is `subprocess.CalledProcessError` +(as if the *subprocess* module had been used), and the `returncode` member +of the exception object can be used to determine the exit code. + +Parameter *stdin* may be a string which is fed to the command as standard input. +The *timeout* is in seconds, with 0 (or a negative number) treated as no-timeout. + +Parameter *callback* is special: +- If it is `None`, no special handling of the command's output is done. + The output will be logged, though (if there is any). +- If it is a list, then the output of the command will be appended to the list, + one line at a time. Lines will still contain the trailing newline character + (if there is one; output may end without a newline). + Use this approach to process the command output after it has completed. +- Anything else is assumed to be a callable function that takes one parameter. + The function is called once for each line of output produced by the command. + The line of output still contains the trailing newline character (if there is one). + Use this approach to process the command output while it is running. + +Here are three examples of running `ls` with different callbacks: +``` +# No processing at all, output is logged +target_env_process_output(["ls"]) +target_env_process_output(["ls"], None) + +# Appends to the list +ls_output = [] +target_env_process_output(["ls"], ls_output) + +# Calls the function for each line, which then calls debug() +def handle_output(s): + debug(f"ls said {s}") +target_env_process_output(["ls"], handle_output) +``` + + +There are additional functions for running commands in the target, +which can select what they return and whether exceptions are raised +or only an exit code is returned. These functions have an overload +that takes a single string (the name of an executable) as well. They should +all be considered deprecated by the callback-enabled functions, above. + +- `target_env_call(command, stdin, timeout)` returns the exit code, does not raise. +- `check_target_env_call(command, stdin, timeout)` raises on a non-zero exit code. +- `check_target_env_output(command, stdin, timeout)` returns a single string with the output of *command*, raises on a non-zero exit code. + +All of the API functions for running commands set the environment +LC_ALL and LANG to "C" for the called command. + +## Process modules + +Use of this kind of module is **not** recommended. Use *shellprocess* +instead, which is more configurable. + +> Type: jobmodule +> Interface: process + +A process jobmodule runs a (single) command. The interface is *process*, +while the module type must be *job* or *jobmodule*. + +The module-descriptor key *command* should have a string as value, which is +passed to the shell -- remember to quote it properly in YAML. It is generally +recommended to use a *shellprocess* job module instead (less configuration, +easier to have multiple instances). There is no configuration outside +of the module-descriptor. The *command* undergoes Calamares variable- +expansion (e.g. replacing `${ROOT}` by the target of the installation). +See *shellprocess* documentation for details. + +Optional keys are *timeout* and *chroot*. + +`CMakeLists.txt` is *not* used for process jobmodules. + + +## Testing Modules + +For testing purposes there is an executable `loadmodule` which is +built, but not installed. It can be found in the build directory. +The `loadmodule` executable behaves like single-module Calamares: +it loads global configuration, job configuration, and then runs +a single module which may be a C++ module or a Python module, +a Job or a ViewModule. + +The same application can also be used to test translations, +branding, and slideshows, without starting up a whole Calamares +each time. It is possible to run multiple `loadmodule` executables +at the same time (Calamares tries to enforce that it runs only +once). + +The following arguments can be used with `loadmodule` +(there are more; run `loadmodule --help` for a complete list): + - `--global` takes a filename and reads the file to provide data in + global storage. The file must be YAML-formatted. + - `--job` takes a filename and reads that to provide the job + configuration (e.g. the `.conf` file for the module). + - `--ui` runs a view module with a UI. Without this option, + view modules are run as jobs, and most of them are not + prepared for that, and will crash. diff --git a/calamares/src/modules/bootloader/bootloader.conf b/calamares/src/modules/bootloader/bootloader.conf new file mode 100644 index 0000000..2105f27 --- /dev/null +++ b/calamares/src/modules/bootloader/bootloader.conf @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Bootloader configuration. The bootloader is installed to allow +# the system to start (and pick one of the installed operating +# systems to run). +# +# Take note that Debian-derivatives that use unmodified GRUB EFI packages +# should specifically set *efiBootloaderId* to "debian" because that is +# hard-coded in `grubx64.efi`. +--- +# A variable from global storage which overrides the value of efiBootLoader +#efiBootLoaderVar: "packagechooser_bootloader" + +# Define which bootloader you want to use for EFI installations +# Possible options are 'grub', 'sb-shim', 'refind` and 'systemd-boot'. +efiBootLoader: "grub" + +# systemd-boot configuration files settings + +# kernelSearchPath is the path relative to the root of the install to search for kernels +# A kernel is identified by finding files which match regular expression, kernelPattern +kernelSearchPath: "/usr/lib/modules" +kernelPattern: "^vmlinuz.*" + +# loaderEntries is an array of options to add to loader.conf for systemd-boot +# please note that the "default" option is added programmatically +loaderEntries: + - "timeout 5" + - "console-mode keep" + +# systemd-boot and refind support custom kernel params +kernelParams: [ "quiet" ] + +# A list of kernel names that refind should accept as kernels +#refindKernelList: [ "linux","linux-lts","linux-zen","linux-hardened" ] + +# GRUB 2 binary names and boot directory +# Some distributions (e.g. Fedora) use grub2-* (resp. /boot/grub2/) names. +# These names are also used when using sb-shim, since that needs some +# GRUB functionality (notably grub-probe) to work. As needed, you may use +# complete paths like `/usr/bin/efibootmgr` for the executables. +# +grubInstall: "grub-install" +grubMkconfig: "grub-mkconfig" +grubCfg: "/boot/grub/grub.cfg" +grubProbe: "grub-probe" +efiBootMgr: "efibootmgr" + +# Optionally set the bootloader ID to use for EFI. This is passed to +# grub-install --bootloader-id. +# +# If not set here, the value from bootloaderEntryName from branding.desc +# is used, with problematic characters (space and slash) replaced. +# +# The ID is also used as a directory name within the EFI environment, +# and the bootloader is copied from /boot/efi/EFI// . When +# setting the option here, keep in mind that the name is sanitized +# (problematic characters, see above, are replaced). +# +# There are some special words possible at the end of *efiBootloaderId*: +# ${SERIAL} can be used to obtain a uniquely-numbered suffix +# that is added to the Id (yielding, e.g., `dirname1` or `dirname72`) +# ${RANDOM} can be used to obtain a unique 4-digit hex suffix +# ${PHRASE} can be used to obtain a unique 1-to-3-word suffix +# from a dictionary of space-themed words +# These words must be at the **end** of the *efiBootloaderId* value. +# There must also be at most one of them. If there is none, no suffix- +# processing is done and the *efiBootloaderId* is used unchanged. +# +# NOTE: Debian derivatives that use the unmodified Debian GRUB EFI +# packages may need to set this to "debian" because that is +# hard-coded in `grubx64.efi`. +# +# efiBootloaderId: "dirname" + +# Optionally install a copy of the GRUB EFI bootloader as the EFI +# fallback loader (either bootia32.efi or bootx64.efi depending on +# the system). This may be needed on certain systems (Intel DH87MC +# seems to be the only one). If you set this to false, take care +# to add another module to optionally install the fallback on those +# boards that need it. +installEFIFallback: true + +# Optionally install both BIOS and UEFI GRUB bootloaders. +installHybridGRUB: false diff --git a/calamares/src/modules/bootloader/bootloader.schema.yaml b/calamares/src/modules/bootloader/bootloader.schema.yaml new file mode 100644 index 0000000..878e761 --- /dev/null +++ b/calamares/src/modules/bootloader/bootloader.schema.yaml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/bootloader +additionalProperties: false +type: object +properties: + efiBootLoaderVar: { type: string } + efiBootLoader: { type: string } + kernelSearchPath: { type: string } + kernelName: { type: string } + kernelParams: { type: array, items: { type: string } } + kernelPattern: { type: string } + loaderEntries: { type: array, items: { type: string } } + refindKernelList: { type: array, items: { type: string } } + + # Programs + grubInstall: { type: string } + grubMkconfig: { type: string } + grubCfg: { type: string } + grubProbe: { type: string } + efiBootMgr: { type: string } + + efiBootloaderId: { type: string } + installEFIFallback: { type: boolean } + installHybridGRUB: { type: boolean } diff --git a/calamares/src/modules/bootloader/main.py b/calamares/src/modules/bootloader/main.py new file mode 100644 index 0000000..5155c14 --- /dev/null +++ b/calamares/src/modules/bootloader/main.py @@ -0,0 +1,974 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Aurélien Gâteau +# SPDX-FileCopyrightText: 2014 Anke Boersma +# SPDX-FileCopyrightText: 2014 Daniel Hillenbrand +# SPDX-FileCopyrightText: 2014 Benjamin Vaudour +# SPDX-FileCopyrightText: 2014-2019 Kevin Kofler +# SPDX-FileCopyrightText: 2015-2018 Philip Mueller +# SPDX-FileCopyrightText: 2016-2017 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot +# SPDX-FileCopyrightText: 2017 Gabriel Craciunescu +# SPDX-FileCopyrightText: 2017 Ben Green +# SPDX-FileCopyrightText: 2021 Neal Gompa +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import fileinput +import os +import re +import shutil +import subprocess + +import libcalamares + +from libcalamares.utils import check_target_env_call, check_target_env_output + +import gettext + +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + +# This is the sanitizer used all over to tidy up filenames +# to make identifiers (or to clean up names to make filenames). +file_name_sanitizer = str.maketrans(" /()", "_-__") + + +def pretty_name(): + return _("Install bootloader.") + + +def get_uuid(): + """ + Checks and passes 'uuid' to other routine. + + :return: + """ + partitions = libcalamares.globalstorage.value("partitions") + + for partition in partitions: + if partition["mountPoint"] == "/": + libcalamares.utils.debug("Root partition uuid: \"{!s}\"".format(partition["uuid"])) + return partition["uuid"] + + return "" + + +def get_kernel_line(kernel_type): + """ + Passes 'kernel_line' to other routine based on configuration file. + + :param kernel_type: + :return: + """ + if kernel_type == "fallback": + if "fallbackKernelLine" in libcalamares.job.configuration: + return libcalamares.job.configuration["fallbackKernelLine"] + else: + return " (fallback)" + else: + if "kernelLine" in libcalamares.job.configuration: + return libcalamares.job.configuration["kernelLine"] + else: + return "" + + +def get_zfs_root(): + """ + Looks in global storage to find the zfs root + + :return: A string containing the path to the zfs root or None if it is not found + """ + + zfs = libcalamares.globalstorage.value("zfsDatasets") + + if not zfs: + libcalamares.utils.warning("Failed to locate zfs dataset list") + return None + + # Find the root dataset + for dataset in zfs: + try: + if dataset["mountpoint"] == "/": + return dataset["zpool"] + "/" + dataset["dsName"] + except KeyError: + # This should be impossible + libcalamares.utils.warning("Internal error handling zfs dataset") + raise + + return None + + +def is_btrfs_root(partition): + """ Returns True if the partition object refers to a btrfs root filesystem + + :param partition: A partition map from global storage + :return: True if btrfs and root, False otherwise + """ + return partition["mountPoint"] == "/" and partition["fs"] == "btrfs" + + +def is_zfs_root(partition): + """ Returns True if the partition object refers to a zfs root filesystem + + :param partition: A partition map from global storage + :return: True if zfs and root, False otherwise + """ + return partition["mountPoint"] == "/" and partition["fs"] == "zfs" + + +def have_program_in_target(program : str): + """Returns @c True if @p program is in path in the target""" + return libcalamares.utils.target_env_call(["/usr/bin/which", program]) == 0 + + +def get_kernel_params(uuid): + # Configured kernel parameters (default "quiet"), if plymouth installed, add splash + # screen parameter and then "rw". + kernel_params = libcalamares.job.configuration.get("kernelParams", ["quiet"]) + if have_program_in_target("plymouth"): + kernel_params.append("splash") + kernel_params.append("rw") + + use_systemd_naming = have_program_in_target("dracut") or (libcalamares.utils.target_env_call(["/usr/bin/grep", "-q", "^HOOKS.*systemd", "/etc/mkinitcpio.conf"]) == 0) + + partitions = libcalamares.globalstorage.value("partitions") + + cryptdevice_params = [] + swap_uuid = "" + swap_outer_mappername = None + swap_outer_uuid = None + + # Take over swap settings: + # - unencrypted swap partition sets swap_uuid + # - encrypted root sets cryptdevice_params + for partition in partitions: + if partition["fs"] == "linuxswap" and not partition.get("claimed", None): + # Skip foreign swap + continue + has_luks = "luksMapperName" in partition + if partition["fs"] == "linuxswap" and not has_luks: + swap_uuid = partition["uuid"] + + if partition["fs"] == "linuxswap" and has_luks: + swap_outer_mappername = partition["luksMapperName"] + swap_outer_uuid = partition["luksUuid"] + + if partition["mountPoint"] == "/" and has_luks: + if use_systemd_naming: + cryptdevice_params = [f"rd.luks.uuid={partition['luksUuid']}"] + else: + cryptdevice_params = [f"cryptdevice=UUID={partition['luksUuid']}:{partition['luksMapperName']}"] + cryptdevice_params.append(f"root=/dev/mapper/{partition['luksMapperName']}") + + # btrfs and zfs handling + for partition in partitions: + # If a btrfs root subvolume wasn't set, it means the root is directly on the partition + # and this option isn't needed + if is_btrfs_root(partition): + btrfs_root_subvolume = libcalamares.globalstorage.value("btrfsRootSubvolume") + if btrfs_root_subvolume: + kernel_params.append("rootflags=subvol=" + btrfs_root_subvolume) + + # zfs needs to be told the location of the root dataset + if is_zfs_root(partition): + zfs_root_path = get_zfs_root() + if zfs_root_path is not None: + kernel_params.append("root=ZFS=" + zfs_root_path) + else: + # Something is really broken if we get to this point + libcalamares.utils.warning("Internal error handling zfs dataset") + raise Exception("Internal zfs data missing, please contact your distribution") + + if cryptdevice_params: + kernel_params.extend(cryptdevice_params) + else: + kernel_params.append("root=UUID={!s}".format(uuid)) + + if swap_uuid: + kernel_params.append("resume=UUID={!s}".format(swap_uuid)) + + if use_systemd_naming and swap_outer_uuid: + kernel_params.append(f"rd.luks.uuid={swap_outer_uuid}") + + if swap_outer_mappername: + kernel_params.append(f"resume=/dev/mapper/{swap_outer_mappername}") + + return kernel_params + + +def create_systemd_boot_conf(installation_root_path, efi_dir, uuid, kernel, kernel_version): + """ + Creates systemd-boot configuration files based on given parameters. + + :param installation_root_path: A string containing the absolute path to the root of the installation + :param efi_dir: A string containing the path to the efi dir relative to the root of the installation + :param uuid: A string containing the UUID of the root volume + :param kernel: A string containing the path to the kernel relative to the root of the installation + :param kernel_version: The kernel version string + """ + + # Get the kernel params and write them to /etc/kernel/cmdline + # This file is used by kernel-install + kernel_params = " ".join(get_kernel_params(uuid)) + kernel_cmdline_path = os.path.join(installation_root_path, "etc", "kernel") + os.makedirs(kernel_cmdline_path, exist_ok=True) + with open(os.path.join(kernel_cmdline_path, "cmdline"), "w") as cmdline_file: + cmdline_file.write(kernel_params) + + libcalamares.utils.debug(f"Configuring kernel version {kernel_version}") + + # get the machine-id + with open(os.path.join(installation_root_path, "etc", "machine-id"), 'r') as machineid_file: + machine_id = machineid_file.read().rstrip('\n') + + # Ensure the directory exists + machine_dir = os.path.join(installation_root_path + efi_dir, machine_id) + os.makedirs(machine_dir, exist_ok=True) + + # Call kernel-install for each kernel + libcalamares.utils.target_env_process_output(["kernel-install", + "add", + kernel_version, + os.path.join("/", kernel)]) + + +def create_loader(loader_path, installation_root_path): + """ + Writes configuration for loader. + + :param loader_path: The absolute path to the loader.conf file + :param installation_root_path: The path to the root of the target installation + """ + + # get the machine-id + with open(os.path.join(installation_root_path, "etc", "machine-id"), 'r') as machineid_file: + machine_id = machineid_file.read().rstrip('\n') + + try: + loader_entries = libcalamares.job.configuration["loaderEntries"] + except KeyError: + libcalamares.utils.debug("No aditional loader entries found in config") + loader_entries = [] + pass + + lines = [f"default {machine_id}*"] + + lines.extend(loader_entries) + + with open(loader_path, 'w') as loader_file: + for line in lines: + loader_file.write(line + "\n") + + +class SuffixIterator(object): + """ + Wrapper for one of the "generator" classes below to behave like + a proper Python iterator. The iterator is initialized with a + maximum number of attempts to generate a new suffix. + """ + + def __init__(self, attempts, generator): + self.generator = generator + self.attempts = attempts + self.counter = 0 + + def __iter__(self): + return self + + def __next__(self): + self.counter += 1 + if self.counter <= self.attempts: + return self.generator.next() + raise StopIteration + + +class serialEfi(object): + """ + EFI Id generator that appends a serial number to the given name. + """ + + def __init__(self, name): + self.name = name + # So the first call to next() will bump it to 0 + self.counter = -1 + + def next(self): + self.counter += 1 + if self.counter > 0: + return "{!s}{!s}".format(self.name, self.counter) + else: + return self.name + + +def render_in_base(value, base_values, length=-1): + """ + Renders @p value in base-N, where N is the number of + items in @p base_values. When rendering, use the items + of @p base_values (e.g. use "0123456789" to get regular decimal + rendering, or "ABCDEFGHIJ" for letters-as-numbers 'encoding'). + + If length is positive, pads out to at least that long with + leading "zeroes", whatever base_values[0] is. + """ + if value < 0: + raise ValueError("Cannot render negative values") + if len(base_values) < 2: + raise ValueError("Insufficient items for base-N rendering") + if length < 1: + length = 1 + digits = [] + base = len(base_values) + while value > 0: + place = value % base + value = value // base + digits.append(base_values[place]) + while len(digits) < length: + digits.append(base_values[0]) + return "".join(reversed(digits)) + + +class randomEfi(object): + """ + EFI Id generator that appends a random 4-digit hex number to the given name. + """ + + def __init__(self, name): + self.name = name + # So the first call to next() will bump it to 0 + self.counter = -1 + + def next(self): + self.counter += 1 + if self.counter > 0: + import random + v = random.randint(0, 65535) # 16 bits + return "{!s}{!s}".format(self.name, render_in_base(v, "0123456789ABCDEF", 4)) + else: + return self.name + + +class phraseEfi(object): + """ + EFI Id generator that appends a random phrase to the given name. + """ + words = ("Sun", "Moon", "Mars", "Soyuz", "Falcon", "Kuaizhou", "Gaganyaan") + + def __init__(self, name): + self.name = name + # So the first call to next() will bump it to 0 + self.counter = -1 + + def next(self): + self.counter += 1 + if self.counter > 0: + import random + desired_length = 1 + self.counter // 5 + v = random.randint(0, len(self.words) ** desired_length) + return "{!s}{!s}".format(self.name, render_in_base(v, self.words)) + else: + return self.name + + +def get_efi_suffix_generator(name): + """ + Handle EFI bootloader Ids with ${} for suffix-processing. + """ + if "${" not in name: + raise ValueError("Misplaced call to get_efi_suffix_generator, no ${}") + if not name.endswith("}"): + raise ValueError("Misplaced call to get_efi_suffix_generator, no trailing ${}") + if name.count("${") > 1: + raise ValueError("EFI ID {!r} contains multiple generators".format(name)) + import re + prefix, generator_name = re.match("(.*)\${([^}]*)}$", name).groups() + if generator_name not in ("SERIAL", "RANDOM", "PHRASE"): + raise ValueError("EFI suffix {!r} is unknown".format(generator_name)) + + generator = None + if generator_name == "SERIAL": + generator = serialEfi(prefix) + elif generator_name == "RANDOM": + generator = randomEfi(prefix) + elif generator_name == "PHRASE": + generator = phraseEfi(prefix) + if generator is None: + raise ValueError("EFI suffix {!r} is unsupported".format(generator_name)) + + return generator + + +def change_efi_suffix(efi_directory, bootloader_id): + """ + Returns a label based on @p bootloader_id that is usable within + @p efi_directory. If there is a ${} suffix marker + in the given id, tries to generate a unique label. + """ + if bootloader_id.endswith("}") and "${" in bootloader_id: + # Do 10 attempts with any suffix generator + g = SuffixIterator(10, get_efi_suffix_generator(bootloader_id)) + else: + # Just one attempt + g = [bootloader_id] + + for candidate_name in g: + if not os.path.exists(os.path.join(efi_directory, candidate_name)): + return candidate_name + return bootloader_id + + +def efi_label(efi_directory): + """ + Returns a sanitized label, possibly unique, that can be + used within @p efi_directory. + """ + if "efiBootloaderId" in libcalamares.job.configuration: + efi_bootloader_id = change_efi_suffix(efi_directory, libcalamares.job.configuration["efiBootloaderId"]) + else: + branding = libcalamares.globalstorage.value("branding") + efi_bootloader_id = branding["bootloaderEntryName"] + + return efi_bootloader_id.translate(file_name_sanitizer) + + +def efi_word_size(): + # get bitness of the underlying UEFI + try: + sysfile = open("/sys/firmware/efi/fw_platform_size", "r") + efi_bitness = sysfile.read(2) + except Exception: + # if the kernel is older than 4.0, the UEFI bitness likely isn't + # exposed to the userspace so we assume a 64 bit UEFI here + efi_bitness = "64" + return efi_bitness + + +def efi_boot_next(): + """ + Tell EFI to definitely boot into the just-installed + system next time. + """ + boot_mgr = libcalamares.job.configuration["efiBootMgr"] + boot_entry = None + efi_bootvars = subprocess.check_output([boot_mgr], universal_newlines=True) + for line in efi_bootvars.split('\n'): + if not line: + continue + words = line.split() + if len(words) >= 2 and words[0] == "BootOrder:": + boot_entry = words[1].split(',')[0] + break + if boot_entry: + subprocess.call([boot_mgr, "-n", boot_entry]) + + +def get_kernels(installation_root_path): + """ + Gets a list of kernels and associated values for each kernel. This will work as is for many distros. + If not, it should be safe to modify it to better support your distro + + :param installation_root_path: A string with the absolute path to the root of the installation + + Returns a list of 3-tuples + + Each 3-tuple contains the kernel, kernel_type and kernel_version + """ + try: + kernel_search_path = libcalamares.job.configuration["kernelSearchPath"] + except KeyError: + libcalamares.utils.warning("No kernel pattern found in configuration, using '/usr/lib/modules'") + kernel_search_path = "/usr/lib/modules" + pass + + kernel_list = [] + + try: + kernel_pattern = libcalamares.job.configuration["kernelPattern"] + except KeyError: + libcalamares.utils.warning("No kernel pattern found in configuration, using 'vmlinuz'") + kernel_pattern = "vmlinuz" + pass + + # find all the installed kernels + for root, dirs, files in os.walk(os.path.join(installation_root_path, kernel_search_path.lstrip('/'))): + for file in files: + if re.search(kernel_pattern, file): + rel_root = os.path.relpath(root, installation_root_path) + kernel_list.append((os.path.join(rel_root, file), "default", os.path.basename(root))) + + return kernel_list + + +def install_clr_boot_manager(): + """ + Installs clr-boot-manager as the bootloader for EFI systems + """ + libcalamares.utils.debug("Bootloader: clr-boot-manager") + + installation_root_path = libcalamares.globalstorage.value("rootMountPoint") + kernel_config_path = os.path.join(installation_root_path, "etc", "kernel") + os.makedirs(kernel_config_path, exist_ok=True) + cmdline_path = os.path.join(kernel_config_path, "cmdline") + + # Get the kernel params + uuid = get_uuid() + kernel_params = " ".join(get_kernel_params(uuid)) + + # Write out the cmdline file for clr-boot-manager + with open(cmdline_path, "w") as cmdline_file: + cmdline_file.write(kernel_params) + + check_target_env_call(["clr-boot-manager", "update"]) + + +def install_systemd_boot(efi_directory): + """ + Installs systemd-boot as bootloader for EFI setups. + + :param efi_directory: + """ + libcalamares.utils.debug("Bootloader: systemd-boot") + installation_root_path = libcalamares.globalstorage.value("rootMountPoint") + install_efi_directory = installation_root_path + efi_directory + uuid = get_uuid() + loader_path = os.path.join(install_efi_directory, + "loader", + "loader.conf") + subprocess.call(["bootctl", + "--path={!s}".format(install_efi_directory), + "install"]) + + for (kernel, kernel_type, kernel_version) in get_kernels(installation_root_path): + create_systemd_boot_conf(installation_root_path, + efi_directory, + uuid, + kernel, + kernel_version) + + create_loader(loader_path, installation_root_path) + + +def get_grub_efi_parameters(): + """ + Returns a 3-tuple of suitable parameters for GRUB EFI installation, + depending on the host machine architecture. The return is + - target name + - grub.efi name + - boot.efi name + all three are strings. May return None if there is no suitable + set for the current machine. May return unsuitable values if the + host architecture is unknown (e.g. defaults to x86_64). + """ + import platform + efi_bitness = efi_word_size() + cpu_type = platform.machine() + + if efi_bitness == "32": + # Assume all 32-bitters are legacy x86 + return "i386-efi", "grubia32.efi", "bootia32.efi" + elif efi_bitness == "64" and cpu_type == "aarch64": + return "arm64-efi", "grubaa64.efi", "bootaa64.efi" + elif efi_bitness == "64" and cpu_type == "loongarch64": + return "loongarch64-efi", "grubloongarch64.efi", "bootloongarch64.efi" + elif efi_bitness == "64": + # If it's not ARM, must by AMD64 + return "x86_64-efi", "grubx64.efi", "bootx64.efi" + libcalamares.utils.warning( + "Could not find GRUB parameters for bits {b} and cpu {c}".format(b=repr(efi_bitness), c=repr(cpu_type))) + return None + + +def run_grub_mkconfig(partitions, output_file): + """ + Runs grub-mkconfig in the target environment + + :param partitions: The partitions list from global storage + :param output_file: A string containing the path to the generating grub config file + :return: + """ + + # zfs needs an environment variable set for grub-mkconfig + if any([is_zfs_root(partition) for partition in partitions]): + check_target_env_call(["sh", "-c", "ZPOOL_VDEV_NAME_PATH=1 " + + libcalamares.job.configuration["grubMkconfig"] + " -o " + output_file]) + else: + # The input file /etc/default/grub should already be filled out by the + # grubcfg job module. + check_target_env_call([libcalamares.job.configuration["grubMkconfig"], "-o", output_file]) + + +def run_grub_install(fw_type, partitions, efi_directory, install_hybrid_grub): + """ + Runs grub-install in the target environment + + :param fw_type: A string which is "efi" for UEFI installs. Any other value results in a BIOS install + :param partitions: The partitions list from global storage + :param efi_directory: The path of the efi directory relative to the root of the install + :return: + """ + libcalamares.utils.debug("run_grub_install------") + + is_zfs = any([is_zfs_root(partition) for partition in partitions]) + + # zfs needs an environment variable set for grub + if is_zfs: + check_target_env_call(["sh", "-c", "echo ZPOOL_VDEV_NAME_PATH=1 >> /etc/environment"]) + + if fw_type == "efi": + assert efi_directory is not None + efi_bootloader_id = efi_label(efi_directory) + efi_target, efi_grub_file, efi_boot_file = get_grub_efi_parameters() + + if is_zfs: + check_target_env_call(["sh", "-c", "ZPOOL_VDEV_NAME_PATH=1 " + libcalamares.job.configuration["grubInstall"] + + " --target=" + efi_target + " --efi-directory=" + efi_directory + + " --bootloader-id=" + efi_bootloader_id + " --force"]) + else: + check_target_env_call([libcalamares.job.configuration["grubInstall"], + "--target=" + efi_target, + "--efi-directory=" + efi_directory, + "--bootloader-id=" + efi_bootloader_id, + "--force"]) + else: + + libcalamares.utils.debug("run_grub_install------else bios 1") + if efi_directory is not None and not install_hybrid_grub: + libcalamares.utils.warning(_("Cannot install BIOS bootloader on UEFI installation when install_hybrid_grub is 'False'!")) + + + if libcalamares.globalstorage.value("bootLoader") is None: + efi_install_path = libcalamares.globalstorage.value("efiSystemPartition") + if efi_install_path is None or efi_install_path == "": + efi_install_path = "/boot/efi" + find_esp_disk_command = f"lsblk -o PKNAME \"$(df --output=source '{efi_install_path}' | tail -n1)\"" + boot_loader_install_path = check_target_env_output(["sh", "-c", find_esp_disk_command]).strip() + if not "\n" in boot_loader_install_path: + libcalamares.utils.warning(_("Cannot find the drive containing the EFI system partition!")) + return + boot_loader_install_path = "/dev/" + boot_loader_install_path.split("\n")[1] + else: + libcalamares.utils.debug("run_grub_install------else bios 1-1 ") + boot_loader = libcalamares.globalstorage.value("bootLoader") + boot_loader_install_path = boot_loader["installPath"] + libcalamares.utils.debug(f"boot_loader_install_path: {boot_loader_install_path}") + if boot_loader_install_path is None: + libcalamares.utils.debug(f"boot_loader_install_path-none: {boot_loader_install_path}") + return + + # boot_loader_install_path points to the physical disk to install GRUB + # to. It should start with "/dev/", and be at least as long as the + # string "/dev/sda". + libcalamares.utils.debug("run_grub_install------else bios 2") + if not boot_loader_install_path.startswith("/dev/") or len(boot_loader_install_path) < 8: + raise ValueError(f"boot_loader_install_path contains unexpected value '{boot_loader_install_path}'") + + if is_zfs: + check_target_env_call(["sh", "-c", "ZPOOL_VDEV_NAME_PATH=1 " + + libcalamares.job.configuration["grubInstall"] + + " --target=i386-pc --recheck --force " + + boot_loader_install_path]) + else: + libcalamares.utils.debug(f"grubInstall-boot_loader_install_path: {boot_loader_install_path}") + check_target_env_call([libcalamares.job.configuration["grubInstall"], + "--target=i386-pc", + "--recheck", + "--force", + boot_loader_install_path]) + + +def install_grub(efi_directory, fw_type, install_hybrid_grub): + """ + Installs grub as bootloader, either in pc or efi mode. + + :param efi_directory: + :param fw_type: + :param install_hybrid_grub: + """ + # get the partition from global storage + partitions = libcalamares.globalstorage.value("partitions") + if not partitions: + libcalamares.utils.warning(_("Failed to install grub, no partitions defined in global storage")) + return + + if fw_type != "bios" and fw_type != "efi": + raise ValueError("fw_type must be 'bios' or 'efi'") + + if fw_type == "efi" or install_hybrid_grub: + libcalamares.utils.debug("Bootloader: grub (efi)") + libcalamares.utils.debug(f"install_hybrid_grub: {install_hybrid_grub}") + installation_root_path = libcalamares.globalstorage.value("rootMountPoint") + install_efi_directory = installation_root_path + efi_directory + + if not os.path.isdir(install_efi_directory): + os.makedirs(install_efi_directory) + + efi_bootloader_id = efi_label(efi_directory) + + efi_target, efi_grub_file, efi_boot_file = get_grub_efi_parameters() + + run_grub_install("efi", partitions, efi_directory, install_hybrid_grub) + + # VFAT is weird, see issue CAL-385 + install_efi_directory_firmware = (vfat_correct_case( + install_efi_directory, + "EFI")) + if not os.path.exists(install_efi_directory_firmware): + os.makedirs(install_efi_directory_firmware) + + # there might be several values for the boot directory + # most usual they are boot, Boot, BOOT + + install_efi_boot_directory = (vfat_correct_case( + install_efi_directory_firmware, + "boot")) + if not os.path.exists(install_efi_boot_directory): + os.makedirs(install_efi_boot_directory) + + # Workaround for some UEFI firmwares + fallback = "installEFIFallback" + libcalamares.utils.debug("UEFI Fallback: " + str(libcalamares.job.configuration.get(fallback, ""))) + if libcalamares.job.configuration.get(fallback, True): + libcalamares.utils.debug(" .. installing '{!s}' fallback firmware".format(efi_boot_file)) + efi_file_source = os.path.join(install_efi_directory_firmware, + efi_bootloader_id, + efi_grub_file) + efi_file_target = os.path.join(install_efi_boot_directory, efi_boot_file) + + shutil.copy2(efi_file_source, efi_file_target) + if fw_type == "bios" or install_hybrid_grub: + libcalamares.utils.debug("Bootloader: grub (bios)") + run_grub_install("bios", partitions, efi_directory, install_hybrid_grub) + + run_grub_mkconfig(partitions, libcalamares.job.configuration["grubCfg"]) + + +def install_secureboot(efi_directory): + """ + Installs the secureboot shim in the system by calling efibootmgr. + """ + efi_bootloader_id = efi_label(efi_directory) + + installation_root_path = libcalamares.globalstorage.value("rootMountPoint") + install_efi_directory = installation_root_path + efi_directory + + if efi_word_size() == "64": + install_efi_bin = "shimx64.efi" + elif efi_word_size() == "32": + install_efi_bin = "shimia32.efi" + else: + libcalamares.utils.warning(f"Unknown efi word size of {efi_word_size()} found") + return None + + # Copied, roughly, from openSUSE's install script, + # and pythonified. *disk* is something like /dev/sda, + # while *drive* may return "(disk/dev/sda,gpt1)" .. + # we're interested in the numbers in the second part + # of that tuple. + efi_drive = subprocess.check_output([ + libcalamares.job.configuration["grubProbe"], + "-t", "drive", "--device-map=", install_efi_directory]).decode("ascii") + efi_disk = subprocess.check_output([ + libcalamares.job.configuration["grubProbe"], + "-t", "disk", "--device-map=", install_efi_directory]).decode("ascii") + + efi_drive_partition = efi_drive.replace("(", "").replace(")", "").split(",")[1] + # Get the first run of digits from the partition + efi_partition_number = None + c = 0 + start = None + while c < len(efi_drive_partition): + if efi_drive_partition[c].isdigit() and start is None: + start = c + if not efi_drive_partition[c].isdigit() and start is not None: + efi_partition_number = efi_drive_partition[start:c] + break + c += 1 + if efi_partition_number is None: + raise ValueError("No partition number found for %s" % install_efi_directory) + + subprocess.call([ + libcalamares.job.configuration["efiBootMgr"], + "-c", + "-w", + "-L", efi_bootloader_id, + "-d", efi_disk, + "-p", efi_partition_number, + "-l", install_efi_directory + "/" + install_efi_bin]) + + efi_boot_next() + + # The input file /etc/default/grub should already be filled out by the + # grubcfg job module. + check_target_env_call([libcalamares.job.configuration["grubMkconfig"], + "-o", os.path.join(efi_directory, "EFI", + efi_bootloader_id, "grub.cfg")]) + + +def vfat_correct_case(parent, name): + for candidate in os.listdir(parent): + if name.lower() == candidate.lower(): + return os.path.join(parent, candidate) + return os.path.join(parent, name) + + +def efi_partitions(efi_boot_path): + """ + The (one) partition mounted on @p efi_boot_path, or an empty list. + """ + return [p for p in libcalamares.globalstorage.value("partitions") if p["mountPoint"] == efi_boot_path] + + +def update_refind_config(efi_directory, installation_root_path): + """ + :param efi_directory: The path to the efi directory relative to the root + :param installation_root_path: The path to the root of the installation + """ + try: + kernel_list = libcalamares.job.configuration["refindKernelList"] + except KeyError: + libcalamares.utils.warning('refindKernelList not set. Skipping updating refind.conf') + return + + # Update the config in the file + for line in fileinput.input(installation_root_path + efi_directory + "/EFI/refind/refind.conf", inplace=True): + line = line.strip() + if line.startswith("#extra_kernel_version_strings") or line.startswith("extra_kernel_version_strings"): + line = line.lstrip("#") + for kernel in kernel_list: + if kernel not in line: + line += "," + kernel + print(line) + + +def install_refind(efi_directory): + try: + installation_root_path = libcalamares.globalstorage.value("rootMountPoint") + except KeyError: + libcalamares.utils.warning('Global storage value "rootMountPoint" missing') + + install_efi_directory = installation_root_path + efi_directory + uuid = get_uuid() + kernel_params = " ".join(get_kernel_params(uuid)) + conf_path = os.path.join(installation_root_path, "boot/refind_linux.conf") + + check_target_env_call(["refind-install"]) + + with open(conf_path, "r") as refind_file: + filedata = [x.strip() for x in refind_file.readlines()] + + with open(conf_path, 'w') as refind_file: + for line in filedata: + if line.startswith('"Boot with standard options"'): + line = f'"Boot with standard options" "{kernel_params}"' + elif line.startswith('"Boot to single-user mode"'): + line = f'"Boot to single-user mode" "{kernel_params}" single' + refind_file.write(line + "\n") + + update_refind_config(efi_directory, installation_root_path) + + +def prepare_bootloader(fw_type, install_hybrid_grub): + """ + Prepares bootloader. + Based on value 'efi_boot_loader', it either calls systemd-boot + or grub to be installed. + + :param fw_type: + :return: + """ + + # Get the boot loader selection from global storage if it is set in the config file + try: + gs_name = libcalamares.job.configuration["efiBootLoaderVar"] + if libcalamares.globalstorage.contains(gs_name): + efi_boot_loader = libcalamares.globalstorage.value(gs_name) + else: + libcalamares.utils.warning( + f"Specified global storage value not found in global storage") + return None + except KeyError: + # If the conf value for using global storage is not set, use the setting from the config file. + try: + efi_boot_loader = libcalamares.job.configuration["efiBootLoader"] + except KeyError: + if fw_type == "efi" or install_hybrid_grub: + libcalamares.utils.warning("Configuration missing both efiBootLoader and efiBootLoaderVar on an EFI-enabled " + "system, bootloader not installed") + return + else: + pass + + # If the user has selected not to install bootloader, bail out here + if efi_boot_loader.casefold() == "none": + libcalamares.utils.debug("Skipping bootloader installation since no bootloader was selected") + return None + + efi_directory = libcalamares.globalstorage.value("efiSystemPartition") + + if efi_boot_loader == "clr-boot-manager": + if fw_type != "efi": + # Grub has to be installed first on non-EFI systems + install_grub(efi_directory, fw_type) + install_clr_boot_manager() + elif efi_boot_loader == "systemd-boot" and fw_type == "efi": + install_systemd_boot(efi_directory) + elif efi_boot_loader == "sb-shim" and fw_type == "efi": + install_secureboot(efi_directory) + elif efi_boot_loader == "refind" and fw_type == "efi": + install_refind(efi_directory) + elif efi_boot_loader == "grub" or fw_type != "efi": + install_grub(efi_directory, fw_type, install_hybrid_grub) + else: + libcalamares.utils.debug("WARNING: the combination of " + "boot-loader '{!s}' and firmware '{!s}' " + "is not supported.".format(efi_boot_loader, fw_type)) + + +def run(): + """ + Starts procedure and passes 'fw_type' to other routine. + + :return: + """ + + fw_type = libcalamares.globalstorage.value("firmwareType") + boot_loader = libcalamares.globalstorage.value("bootLoader") + + install_hybrid_grub = libcalamares.job.configuration.get("installHybridGRUB", False) + efi_boot_loader = libcalamares.job.configuration.get("efiBootLoader", "") + + if install_hybrid_grub == True and efi_boot_loader != "grub": + raise ValueError(f"efi_boot_loader '{efi_boot_loader}' is illegal when install_hybrid_grub is 'true'!") + + if boot_loader is None and fw_type != "efi": + libcalamares.utils.warning("Non-EFI system, and no bootloader is set.") + return None + + partitions = libcalamares.globalstorage.value("partitions") + if fw_type == "efi": + efi_system_partition = libcalamares.globalstorage.value("efiSystemPartition") + esp_found = [p for p in partitions if p["mountPoint"] == efi_system_partition] + if not esp_found: + libcalamares.utils.warning("EFI system, but nothing mounted on {!s}".format(efi_system_partition)) + return None + + try: + prepare_bootloader(fw_type, install_hybrid_grub) + except subprocess.CalledProcessError as e: + libcalamares.utils.warning(str(e)) + libcalamares.utils.debug("stdout:" + str(e.stdout)) + libcalamares.utils.debug("stderr:" + str(e.stderr)) + return (_("Bootloader installation error"), + _("The bootloader could not be installed. The installation command
{!s}
returned error " + "code {!s}.") + .format(e.cmd, e.returncode)) + + return None diff --git a/calamares/src/modules/bootloader/module.desc b/calamares/src/modules/bootloader/module.desc new file mode 100644 index 0000000..44a1c0e --- /dev/null +++ b/calamares/src/modules/bootloader/module.desc @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +interface: "python" +name: "bootloader" +script: "main.py" +# The partition module sets up the EFI firmware type +# global key, which is used to decide how to install. +requiredModules: [ "partition" ] diff --git a/calamares/src/modules/bootloader/test.yaml b/calamares/src/modules/bootloader/test.yaml new file mode 100644 index 0000000..4623b55 --- /dev/null +++ b/calamares/src/modules/bootloader/test.yaml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +rootMountPoint: /tmp/mount +bootLoader: + installPath: /dev/sdb +branding: + shortProductName: "Generic Distro" diff --git a/calamares/src/modules/bootloader/tests/CMakeTests.txt b/calamares/src/modules/bootloader/tests/CMakeTests.txt new file mode 100644 index 0000000..e135292 --- /dev/null +++ b/calamares/src/modules/bootloader/tests/CMakeTests.txt @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# We have tests to exercise some of the module internals. +# Those tests conventionally live in Python files here in the tests/ directory. Add them. +add_test( + NAME test-bootloader-efiname + COMMAND env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-bootloader-efiname.py + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) diff --git a/calamares/src/modules/bootloader/tests/test-bootloader-efiname.py b/calamares/src/modules/bootloader/tests/test-bootloader-efiname.py new file mode 100644 index 0000000..4756fd7 --- /dev/null +++ b/calamares/src/modules/bootloader/tests/test-bootloader-efiname.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.bootloader import main + +# Specific Bootloader test +g = main.get_efi_suffix_generator("derp${SERIAL}") +assert g is not None +assert g.next() == "derp" # First time, no suffix +for n in range(9): + print(g.next()) +# We called next() 10 times in total, starting from 0 +assert g.next() == "derp10" + +g = main.get_efi_suffix_generator("derp${RANDOM}") +assert g is not None +for n in range(10): + print(g.next()) +# it's random, nothing to assert + +g = main.get_efi_suffix_generator("derp${PHRASE}") +assert g is not None +for n in range(10): + print(g.next()) +# it's random, nothing to assert + +# Check invalid things +try: + g = main.get_efi_suffix_generator("derp") + raise TypeError("Shouldn't get generator (no indicator)") +except ValueError as e: + pass + +try: + g = main.get_efi_suffix_generator("derp${HEX}") + raise TypeError("Shouldn't get generator (unknown indicator)") +except ValueError as e: + pass + +try: + g = main.get_efi_suffix_generator("derp${SERIAL}x") + raise TypeError("Shouldn't get generator (trailing garbage)") +except ValueError as e: + pass + +try: + g = main.get_efi_suffix_generator("derp${SERIAL}${RANDOM}") + raise TypeError("Shouldn't get generator (multiple indicators)") +except ValueError as e: + pass + + +# Try the generator (assuming no calamares- test files exist in /tmp) +import os +assert "calamares-single" == main.change_efi_suffix("/tmp", "calamares-single") +assert "calamares-serial" == main.change_efi_suffix("/tmp", "calamares-serial${SERIAL}") +try: + os.makedirs("/tmp/calamares-serial", exist_ok=True) + assert "calamares-serial1" == main.change_efi_suffix("/tmp", "calamares-serial${SERIAL}") +finally: + os.rmdir("/tmp/calamares-serial") diff --git a/calamares/src/modules/contextualprocess/Binding.h b/calamares/src/modules/contextualprocess/Binding.h new file mode 100644 index 0000000..186118f --- /dev/null +++ b/calamares/src/modules/contextualprocess/Binding.h @@ -0,0 +1,84 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* This file isn't public API, but is used to express the API that + * the tests for ContextualProcess can work with. + */ +#ifndef CONTEXTUALPROCESSJOB_BINDING_H +#define CONTEXTUALPROCESSJOB_BINDING_H + +#include "Job.h" + +#include +#include +#include + +namespace Calamares +{ +class CommandList; +class GlobalStorage; +} // namespace Calamares + +struct ValueCheck : public QPair< QString, Calamares::CommandList* > +{ + ValueCheck( const QString& value, Calamares::CommandList* commands ) + : QPair< QString, Calamares::CommandList* >( value, commands ) + { + } + + // ~ValueCheck() + // + // There is no destructor. + // + // We don't own the commandlist, the binding holding this valuecheck + // does, so don't delete. This is closely tied to (temporaries created + // by) pass-by-value in QList::append(). + + QString value() const { return first; } + Calamares::CommandList* commands() const { return second; } +}; + +class ContextualProcessBinding +{ +public: + ContextualProcessBinding( const QString& varname ) + : m_variable( varname ) + { + } + + ~ContextualProcessBinding(); + + QString variable() const { return m_variable; } + int count() const { return m_checks.count(); } + + /** + * @brief add commands to be executed when @p value is matched. + * + * Ownership of the CommandList passes to this binding. + */ + void append( const QString& value, Calamares::CommandList* commands ); + + ///@brief The bound variable has @p value , run the associated commands. + Calamares::JobResult run( const QString& value ) const; + + /** @brief Tries to obtain this binding's value from GS + * + * Stores the value in @p value and returns true if a value + * was found (e.g. @p storage contains the variable this binding + * is for) and false otherwise. + */ + bool fetch( Calamares::GlobalStorage* storage, QString& value ) const; + +private: + QString m_variable; + QList< ValueCheck > m_checks; + Calamares::CommandList* m_wildcard = nullptr; +}; + +#endif diff --git a/calamares/src/modules/contextualprocess/CMakeLists.txt b/calamares/src/modules/contextualprocess/CMakeLists.txt new file mode 100644 index 0000000..9520515 --- /dev/null +++ b/calamares/src/modules/contextualprocess/CMakeLists.txt @@ -0,0 +1,20 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(contextualprocess + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + ContextualProcessJob.cpp + SHARED_LIB +) + +calamares_add_test( + contextualprocesstest + SOURCES + Tests.cpp + ContextualProcessJob.cpp # Builds it a second time + LIBRARIES yamlcpp::yamlcpp +) diff --git a/calamares/src/modules/contextualprocess/ContextualProcessJob.cpp b/calamares/src/modules/contextualprocess/ContextualProcessJob.cpp new file mode 100644 index 0000000..9b34db4 --- /dev/null +++ b/calamares/src/modules/contextualprocess/ContextualProcessJob.cpp @@ -0,0 +1,179 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ContextualProcessJob.h" + +#include "Binding.h" + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "compat/Variant.h" +#include "utils/CommandList.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +ContextualProcessBinding::~ContextualProcessBinding() +{ + m_wildcard = nullptr; + for ( const auto& c : m_checks ) + { + delete c.commands(); + } +} + +void +ContextualProcessBinding::append( const QString& value, Calamares::CommandList* commands ) +{ + m_checks.append( ValueCheck( value, commands ) ); + if ( value == QString( "*" ) ) + { + m_wildcard = commands; + } +} + +Calamares::JobResult +ContextualProcessBinding::run( const QString& value ) const +{ + for ( const auto& c : m_checks ) + { + if ( value == c.value() ) + { + return c.commands()->run(); + } + } + + if ( m_wildcard ) + { + return m_wildcard->run(); + } + + return Calamares::JobResult::ok(); +} + +bool +ContextualProcessBinding::fetch( Calamares::GlobalStorage* storage, QString& value ) const +{ + value.clear(); + bool ok = false; + const auto v = Calamares::lookup( storage, m_variable, ok ); + if ( !ok ) + { + return false; + } + value = v.toString(); + return true; +} + +ContextualProcessJob::ContextualProcessJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +ContextualProcessJob::~ContextualProcessJob() +{ + qDeleteAll( m_commands ); +} + +QString +ContextualProcessJob::prettyName() const +{ + return tr( "Performing contextual processes' job…", "@status" ); +} + +Calamares::JobResult +ContextualProcessJob::exec() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + for ( const ContextualProcessBinding* binding : m_commands ) + { + QString value; + if ( binding->fetch( gs, value ) ) + { + Calamares::JobResult r = binding->run( value ); + if ( !r ) + { + return r; + } + } + else + { + cWarning() << "ContextualProcess checks for unknown variable" << binding->variable(); + } + } + return Calamares::JobResult::ok(); +} + +void +ContextualProcessJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + bool dontChroot = Calamares::getBool( configurationMap, "dontChroot", false ); + qint64 timeout = Calamares::getInteger( configurationMap, "timeout", 10 ); + if ( timeout < 1 ) + { + timeout = 10; + } + + for ( QVariantMap::const_iterator iter = configurationMap.cbegin(); iter != configurationMap.cend(); ++iter ) + { + QString variableName = iter.key(); + if ( variableName.isEmpty() || ( variableName == "dontChroot" ) || ( variableName == "timeout" ) ) + { + continue; + } + + if ( Calamares::typeOf( iter.value() ) != Calamares::MapVariantType ) + { + cWarning() << moduleInstanceKey() << "bad configuration values for" << variableName; + continue; + } + + auto binding = new ContextualProcessBinding( variableName ); + m_commands.append( binding ); + QVariantMap values = iter.value().toMap(); + for ( QVariantMap::const_iterator valueiter = values.cbegin(); valueiter != values.cend(); ++valueiter ) + { + QString valueString = valueiter.key(); + if ( variableName.isEmpty() ) + { + cWarning() << moduleInstanceKey() << "variable" << variableName << "unrecognized value" + << valueiter.key(); + continue; + } + + Calamares::CommandList* commands + = new Calamares::CommandList( valueiter.value(), !dontChroot, std::chrono::seconds( timeout ) ); + + binding->append( valueString, commands ); + } + } +} + +int +ContextualProcessJob::count() +{ + return m_commands.count(); +} + +int +ContextualProcessJob::count( const QString& variableName ) +{ + for ( const ContextualProcessBinding* binding : m_commands ) + { + if ( binding->variable() == variableName ) + { + return binding->count(); + } + } + return -1; +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( ContextualProcessJobFactory, registerPlugin< ContextualProcessJob >(); ) diff --git a/calamares/src/modules/contextualprocess/ContextualProcessJob.h b/calamares/src/modules/contextualprocess/ContextualProcessJob.h new file mode 100644 index 0000000..8d58a3c --- /dev/null +++ b/calamares/src/modules/contextualprocess/ContextualProcessJob.h @@ -0,0 +1,48 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CONTEXTUALPROCESSJOB_H +#define CONTEXTUALPROCESSJOB_H + +#include +#include + +#include "CppJob.h" +#include "DllMacro.h" + +#include "utils/PluginFactory.h" + +class ContextualProcessBinding; + +class PLUGINDLLEXPORT ContextualProcessJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit ContextualProcessJob( QObject* parent = nullptr ); + ~ContextualProcessJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + /// The number of bindings + int count(); + /// The number of value-checks for the named binding (-1 if binding doesn't exist) + int count( const QString& variableName ); + +private: + QList< ContextualProcessBinding* > m_commands; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( ContextualProcessJobFactory ) + +#endif // CONTEXTUALPROCESSJOB_H diff --git a/calamares/src/modules/contextualprocess/Tests.cpp b/calamares/src/modules/contextualprocess/Tests.cpp new file mode 100644 index 0000000..3a5ca31 --- /dev/null +++ b/calamares/src/modules/contextualprocess/Tests.cpp @@ -0,0 +1,182 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Tests.h" + +#include "Binding.h" +#include "ContextualProcessJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/CommandList.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include + +#include +#include + +QTEST_GUILESS_MAIN( ContextualProcessTests ) + +using CommandList = Calamares::CommandList; + +ContextualProcessTests::ContextualProcessTests() {} + +ContextualProcessTests::~ContextualProcessTests() {} + +void +ContextualProcessTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + // Ensure we have a system object, expect it to be a "bogus" one + Calamares::System* system = Calamares::System::instance(); + QVERIFY( system ); + QVERIFY( system->doChroot() ); + + // Ensure we have a system-wide GlobalStorage with /tmp as root + if ( !Calamares::JobQueue::instance() ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + } + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); +} + +void +ContextualProcessTests::testProcessListSampleConfig() +{ + YAML::Node doc; + + QStringList dirs { "src/modules/contextualprocess", "." }; + for ( const auto& dir : dirs ) + { + QString filename = dir + "/contextualprocess.conf"; + if ( QFileInfo::exists( filename ) ) + { + doc = YAML::LoadFile( filename.toStdString() ); + break; + } + } + + ContextualProcessJob job; + job.setConfigurationMap( Calamares::YAML::mapToVariant( doc ) ); + + QCOMPARE( job.count(), 2 ); // Only "firmwareType" and "branding.shortVersion" + QCOMPARE( job.count( "firmwareType" ), 4 ); + QCOMPARE( job.count( "branding.shortVersion" ), 2 ); // in the example config +} + +void +ContextualProcessTests::testFetch() +{ + Logger::setupLogLevel( Logger::LOGVERBOSE ); + + QVariantMap m; + // Some keys without sub-map + m.insert( QStringLiteral( "carrot" ), true ); + m.insert( QStringLiteral( "tomato" ), QStringLiteral( "fruit" ) ); + + // A key with sub-map + { + QVariantMap names; + names.insert( QStringLiteral( "blackcurrant" ), QStringLiteral( "black" ) ); + names.insert( QStringLiteral( "redcurrant" ), QStringLiteral( "red" ) ); + names.insert( QStringLiteral( "knoebels" ), QStringLiteral( "green" ) ); + names.insert( QStringLiteral( "strawberry" ), QStringLiteral( "red" ) ); + m.insert( QStringLiteral( "berries" ), names ); + } + + // Another key with sub-map + { + QVariantMap names; + names.insert( QStringLiteral( "ext4" ), 1 ); + names.insert( QStringLiteral( "zfs" ), 2 ); + names.insert( QStringLiteral( "swap" ), 2 ); + m.insert( QStringLiteral( "filesystem_use" ), names ); + } + + QCOMPARE( m.count(), 4 ); + + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); + + // Copy the built-up-map into GS + for ( auto it = m.cbegin(); it != m.cend(); ++it ) + { + gs->insert( it.key(), it.value() ); + } + + // Testing of fetch() + { + ContextualProcessBinding b( QStringLiteral( "carrot" ) ); + QString s; + QVERIFY( b.fetch( gs, s ) ); + QCOMPARE( s, QStringLiteral( "true" ) ); // String representation of boolean true + } + { + ContextualProcessBinding b( QStringLiteral( "tomato" ) ); + QString s; + QVERIFY( b.fetch( gs, s ) ); + QCOMPARE( s, QStringLiteral( "fruit" ) ); + } + { + // Key not found + ContextualProcessBinding b( QStringLiteral( "parsnip" ) ); + QString s = QStringLiteral( "white" ); + QVERIFY( !b.fetch( gs, s ) ); + QCOMPARE( s, QString() ); + QVERIFY( s.isEmpty() ); + } + { + // Submap gets smashed + ContextualProcessBinding b( QStringLiteral( "berries" ) ); + QString s; + QVERIFY( b.fetch( gs, s ) ); + QVERIFY( s.isEmpty() ); // No string representation + } + { + // Compound lookup + ContextualProcessBinding b( QStringLiteral( "berries.strawberry" ) ); + QString s; + QVERIFY( b.fetch( gs, s ) ); + QCOMPARE( s, QStringLiteral( "red" ) ); + } + { + ContextualProcessBinding b( QStringLiteral( "berries.knoebels" ) ); + QString s; + QVERIFY( b.fetch( gs, s ) ); + QCOMPARE( s, QStringLiteral( "green" ) ); + } + { + ContextualProcessBinding b( QStringLiteral( "filesystem_use.ext4" ) ); + QString s; + QVERIFY( b.fetch( gs, s ) ); + QCOMPARE( s, QStringLiteral( "1" ) ); + } + { + ContextualProcessBinding b( QStringLiteral( "filesystem_use.zfs" ) ); + QString s; + QVERIFY( b.fetch( gs, s ) ); + QCOMPARE( s, QStringLiteral( "2" ) ); + } + { + // Key not found, compound + ContextualProcessBinding b( QStringLiteral( "filesystem_use.ufs" ) ); + QString s = QStringLiteral( "ufs" ); + QVERIFY( !b.fetch( gs, s ) ); + QCOMPARE( s, QString() ); + QVERIFY( s.isEmpty() ); + } +} diff --git a/calamares/src/modules/contextualprocess/Tests.h b/calamares/src/modules/contextualprocess/Tests.h new file mode 100644 index 0000000..0aed1f4 --- /dev/null +++ b/calamares/src/modules/contextualprocess/Tests.h @@ -0,0 +1,31 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TESTS_H +#define TESTS_H + +#include + +class ContextualProcessTests : public QObject +{ + Q_OBJECT +public: + ContextualProcessTests(); + ~ContextualProcessTests() override; + +private Q_SLOTS: + void initTestCase(); + // Check the sample config file is processed correctly + void testProcessListSampleConfig(); + + // Variable binding lookup + void testFetch(); +}; + +#endif diff --git a/calamares/src/modules/contextualprocess/contextualprocess.conf b/calamares/src/modules/contextualprocess/contextualprocess.conf new file mode 100644 index 0000000..e5c1c1d --- /dev/null +++ b/calamares/src/modules/contextualprocess/contextualprocess.conf @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the contextual process job. +# +# Contextual processes are based on **global** configuration values. +# When a given global value (string) equals a given value, then +# the associated command is executed. +# +# Configuration consists of keys for global variable names (except +# *dontChroot* and *timeout*), and the sub-keys are strings to compare +# to the variable's value. If the variable has that particular value, the +# corresponding value (script) is executed. The top-level keys *dontChroot* +# and *timeout* are not global variable names. They have +# meaning just like in shellprocess.conf, that is they +# determine **where** the command runs and how long it has. +# +# The variable **may** contain dots, in which case the dot is used +# to select into maps inside global storage, e.g. +# +# - *firmwareType* is a simple global name +# - *branding.bootloader* is the *bootloader* value in the *branding* map +# +# Only a few global storage entries have well-defined sub-maps; +# branding is one of them, and *filesystem_use* is another. Note that +# variable names with dots **must** be quoted, or you will get a YAML error. +# +# +# You can check for an empty value with "". +# +# As a special case, the value-check "*" matches any value, but **only** +# if no other value-check matches. Use it as an *else* form for value- +# checks. Take care to put the asterisk in quotes. The value-check "*" +# **also** matches a literal asterisk as value; a confusing corner case +# is checking for an asterisk **and** having a wildcard match with +# different commands. This is currently not possible. +# +# Global configuration variables are not checked in a deterministic +# order, so do not rely on commands from one variable-check to +# always happen before (or after) checks on another +# variable. Similarly, the value-equality checks are not +# done in a deterministic order, but all of the value-checks +# for a given variable happen together. As a special case, the +# value-check for "*" (the *else* case) happens after all of the +# other value-checks, and only matches if none of the others do. +# +# The values after a value sub-keys are the same kinds of values +# as can be given to the *script* key in the shellprocess module. +# See shellprocess.conf for documentation on valid values and how +# variables are expanded in those commands. +--- +dontChroot: false +firmwareType: + efi: + - "-pkg remove efi-firmware" + - command: "-mkinitramfsrd -abgn" + timeout: 120 # This is slow + bios: "-pkg remove bios-firmware" + "": "/bin/false no-firmware-type-set" + "*": "/bin/false some-other-firmware-value" +"branding.shortVersion": + "2020.2": "/bin/false february" + "2019.4": "/bin/true april" diff --git a/calamares/src/modules/displaymanager/displaymanager.conf b/calamares/src/modules/displaymanager/displaymanager.conf new file mode 100644 index 0000000..d0a6a35 --- /dev/null +++ b/calamares/src/modules/displaymanager/displaymanager.conf @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configure one or more display managers (e.g. SDDM) +# with a "best effort" approach. +# +# This module also sets up autologin, if the feature is enabled in +# globalstorage (where it would come from the users page). +--- +# The DM module attempts to set up all the DMs found in this list, in the +# precise order listed. The displaymanagers list can also be set in +# globalstorage, and in that case it overrides the setting here. +# +# If *sysconfigSetup* is set to *true* (see below, only relevant for +# openSUSE derivatives) then this list is ignored and only sysconfig +# is attempted. You can also list "sysconfig" in this list instead. +# +displaymanagers: + - slim + - sddm + - lightdm + - gdm + - mdm + - lxdm + - greetd + +# Enable the following settings to force a desktop environment +# in your displaymanager configuration file. This will attempt +# to configure the given DE (without checking if it is installed). +# The DM configuration for each potential DM may **or may not** +# support configuring a default DE, so the keys are mandatory +# but their interpretation is up to the DM configuration. +# +# Subkeys of *defaultDesktopEnvironment* are (all mandatory): +# - *executable* a full path to an executable +# - *desktopFile* a .desktop filename +# +# If this is **not** set, then Calamares will look for installed +# DE's and pick the first one it finds that is actually installed. +# +# If this **is** set, and the *executable* key doesn't point to +# an installed file, then the .desktop file's TryExec key is +# used instead. +# + +#defaultDesktopEnvironment: +# executable: "startkde" +# desktopFile: "plasma" + +#If true, try to ensure that the user, group, /var directory etc. for the +#display manager are set up correctly. This is normally done by the distribution +#packages, and best left to them. Therefore, it is disabled by default. +basicSetup: false + +# If true, setup autologin for openSUSE. This only makes sense on openSUSE +# derivatives or other systems where /etc/sysconfig/displaymanager exists. +# +# The preferred way to pick sysconfig is to just list it in the +# *displaymanagers* list (as the only one). +# +sysconfigSetup: false + +# Some DMs have specific settings. These can be customized here. +# +# greetd has configurable user and group; the user and group is created if it +# does not exist, and the user is set as default-session user. +# +# Some greeters for greetd (e.g gtkgreet or regreet) have support for a user's GTK CSS style to change appearance. +# +# lightdm has a list of greeters to look for, preferring them in order if +# they are installed (if not, picks the alphabetically first greeter that is installed). +# +greetd: + greeter_user: "tom_bombadil" + greeter_group: "wheel" + greeter_css_location: "/etc/greetd/style.css" +lightdm: + preferred_greeters: ["lightdm-greeter.desktop", "slick-greeter.desktop"] +sddm: + configuration_file: "/etc/sddm.conf" diff --git a/calamares/src/modules/displaymanager/displaymanager.schema.yaml b/calamares/src/modules/displaymanager/displaymanager.schema.yaml new file mode 100644 index 0000000..dcd2baa --- /dev/null +++ b/calamares/src/modules/displaymanager/displaymanager.schema.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/displaymanager +additionalProperties: false +type: object +properties: + displaymanagers: + type: array + items: + type: string + enum: [slim, sddm, lightdm, gdm, mdm, lxdm, greetd] + minItems: 1 # Must be non-empty, if present at all + defaultDesktopEnvironment: + type: object + properties: + executable: { type: string } + desktopFile: { type: string } + required: [ executable, desktopFile ] + basicSetup: { type: boolean, default: false } + sysconfigSetup: { type: boolean, default: false } + greetd: + type: object + properties: + greeter_user: { type: string } + greeter_group: { type: string } + greeter_css_location: { type: string } + additionalProperties: false + lightdm: + type: object + properties: + preferred_greeters: { type: array, items: { type: string } } + additionalProperties: false + sddm: + type: object + properties: + configuration_file: { type: string } + additionalProperties: false diff --git a/calamares/src/modules/displaymanager/main.py b/calamares/src/modules/displaymanager/main.py new file mode 100644 index 0000000..6ca279e --- /dev/null +++ b/calamares/src/modules/displaymanager/main.py @@ -0,0 +1,1046 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014-2018 Philip Müller +# SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac +# SPDX-FileCopyrightText: 2014 Kevin Kofler +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017 Bernhard Landauer +# SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot +# SPDX-FileCopyrightText: 2019 Dominic Hayes +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import abc +import os +import libcalamares + +from libcalamares.utils import gettext_path, gettext_languages + +import gettext +_translation = gettext.translation("calamares-python", + localedir=gettext_path(), + languages=gettext_languages(), + fallback=True) +_ = _translation.gettext +_n = _translation.ngettext + +class DesktopEnvironment: + """ + Desktop Environment -- some utility functions for a desktop + environment (e.g. finding out if it is installed). This + is independent of the *Display Manager*, which is what + we're configuring in this module. + """ + def __init__(self, exec, desktop): + self.executable = exec + self.desktop_file = desktop + + def _search_executable(self, root_mount_point, pathname): + """ + Search for @p pathname within @p root_mount_point . + If the pathname is absolute, just check there inside + the target, otherwise earch in a sort-of-sensible $PATH. + + Returns the full (including @p root_mount_point) path + to that executable, or None. + """ + if pathname.startswith("/"): + path = [""] + else: + path = ["/bin/", "/usr/bin/", "/sbin/", "/usr/local/bin/"] + + for p in path: + absolute_path = "{!s}{!s}{!s}".format(root_mount_point, p, pathname) + if os.path.exists(absolute_path): + return absolute_path + return None + + def _search_tryexec(self, root_mount_point, absolute_desktop_file): + """ + Check @p absolute_desktop_file for a TryExec line and, if that is + found, search for the command (executable pathname) within + @p root_mount_point. The .desktop file must live within the + target root. + + Returns the full (including @p root_mount_point) for the executable + from TryExec, or None. + """ + assert absolute_desktop_file.startswith(root_mount_point) + with open(absolute_desktop_file, "r") as f: + for tryexec_line in [x for x in f.readlines() if x.startswith("TryExec")]: + try: + key, value = tryexec_line.split("=") + if key.strip() == "TryExec": + return self._search_executable(root_mount_point, value.strip()) + except: + pass + return None + + def find_executable(self, root_mount_point): + """ + Returns the full path of the configured executable within @p root_mount_point, + or None if it isn't found. May search in a semi-sensible $PATH. + """ + return self._search_executable(root_mount_point, self.executable) + + def find_desktop_file(self, root_mount_point): + """ + Returns the full path of the .desktop file within @p root_mount_point, + or None if it isn't found. Searches both X11 and Wayland sessions. + """ + x11_sessions = "{!s}/usr/share/xsessions/{!s}.desktop".format(root_mount_point, self.desktop_file) + wayland_sessions = "{!s}/usr/share/wayland-sessions/{!s}.desktop".format(root_mount_point, self.desktop_file) + for candidate in (x11_sessions, wayland_sessions): + if os.path.exists(candidate): + return candidate + return None + + def is_installed(self, root_mount_point): + """ + Check if this environment is installed in the + target system at @p root_mount_point. + """ + desktop_file = self.find_desktop_file(root_mount_point) + if desktop_file is None: + return False + + return (self.find_executable(root_mount_point) is not None or + self._search_tryexec(root_mount_point, desktop_file) is not None) + + def update_from_desktop_file(self, root_mount_point): + """ + Find thie DE in the target system at @p root_mount_point. + This can update the *executable* configuration value if + the configured executable isn't found but the TryExec line + from the .desktop file is. + + The .desktop file is mandatory for a DE. + + Returns True if the DE is installed. + """ + desktop_file = self.find_desktop_file(root_mount_point) + if desktop_file is None: + return False + + executable_file = self.find_executable(root_mount_point) + if executable_file is not None: + # .desktop found and executable as well. + return True + + executable_file = self._search_tryexec(root_mount_point, desktop_file) + if executable_file is not None: + # Found from the .desktop file, so update own executable config + if root_mount_point and executable_file.startswith(root_mount_point): + executable_file = executable_file[len(root_mount_point):] + if not executable_file: + # Somehow chopped down to nothing + return False + + if executable_file[0] != "/": + executable_file = "/" + executable_file + self.executable = executable_file + return True + # This is to double-check + return self.is_installed(root_mount_point) + + +# This is the list of desktop environments that Calamares looks +# for; if no default environment is **explicitly** configured +# in the `displaymanager.conf` then the first one from this list +# that is found, is used. +# +# Each DE has a sample executable to look for, and a .desktop filename. +# If the executable exists, the DE is assumed to be installed +# and to use the given .desktop filename. +# +# If the .desktop file exists and contains a TryExec line and that +# TryExec executable exists (searched in /bin, /usr/bin, /sbin and +# /usr/local/bin) then the DE is assumed to be installed +# and to use that .desktop filename. +desktop_environments = [ + DesktopEnvironment('/usr/bin/startplasma-x11', 'plasma'), # KDE Plasma 5.17+ + DesktopEnvironment('/usr/bin/startkde', 'plasma'), # KDE Plasma 5 + DesktopEnvironment('/usr/bin/startkde', 'kde-plasma'), # KDE Plasma 4 + DesktopEnvironment( + '/usr/bin/budgie-desktop', 'budgie-desktop' # Budgie v10 + ), + DesktopEnvironment( + '/usr/bin/budgie-session', 'budgie-desktop' # Budgie v8 + ), + DesktopEnvironment('/usr/bin/gnome-session', 'gnome'), + DesktopEnvironment('/usr/bin/cinnamon-session-cinnamon', 'cinnamon'), + DesktopEnvironment('/usr/bin/mate-session', 'mate'), + DesktopEnvironment('/usr/bin/enlightenment_start', 'enlightenment'), + DesktopEnvironment('/usr/bin/lxsession', 'LXDE'), + DesktopEnvironment('/usr/bin/startlxde', 'LXDE'), + DesktopEnvironment('/usr/bin/lxqt-session', 'lxqt'), + DesktopEnvironment('/usr/bin/pekwm', 'pekwm'), + DesktopEnvironment('/usr/bin/pantheon-session', 'pantheon'), + DesktopEnvironment('/usr/bin/startdde', 'deepin'), + DesktopEnvironment('/usr/bin/startxfce4', 'xfce'), + DesktopEnvironment('/usr/bin/openbox-session', 'openbox'), + DesktopEnvironment('/usr/bin/i3', 'i3'), + DesktopEnvironment('/usr/bin/awesome', 'awesome'), + DesktopEnvironment('/usr/bin/bspwm', 'bspwm'), + DesktopEnvironment('/usr/bin/herbstluftwm', 'herbstluftwm'), + DesktopEnvironment('/usr/bin/qtile', 'qtile'), + DesktopEnvironment('/usr/bin/xmonad', 'xmonad'), + DesktopEnvironment('/usr/bin/dwm', 'dwm'), + DesktopEnvironment('/usr/bin/jwm', 'jwm'), + DesktopEnvironment('/usr/bin/icewm-session', 'icewm-session'), + DesktopEnvironment('/usr/bin/fvwm3', 'fvwm3'), + DesktopEnvironment('/usr/bin/sway', 'sway'), + DesktopEnvironment('/usr/bin/ukui-session', 'ukui'), + DesktopEnvironment('/usr/bin/cutefish-session', 'cutefish-xsession'), + DesktopEnvironment('/usr/bin/river', 'river'), + DesktopEnvironment('/usr/bin/Hyprland', 'hyprland'), +] + + +def find_desktop_environment(root_mount_point): + """ + Checks which desktop environment is currently installed. + + :param root_mount_point: + :return: + """ + libcalamares.utils.debug("Using rootMountPoint {!r}".format(root_mount_point)) + for desktop_environment in desktop_environments: + if desktop_environment.is_installed(root_mount_point): + libcalamares.utils.debug(".. selected DE {!s}".format(desktop_environment.desktop_file)) + return desktop_environment + return None + + +class DisplayManager(metaclass=abc.ABCMeta): + """ + Display Manager -- a base class for DM configuration. + """ + name = None + executable = None + + def __init__(self, root_mount_point): + self.root_mount_point = root_mount_point + + def have_dm(self): + """ + Is this DM installed in the target system? + The default implementation checks for `executable` + in the target system. + """ + if self.executable is None: + return False + + bin_path = "{!s}/usr/bin/{!s}".format(self.root_mount_point, self.executable) + sbin_path = "{!s}/usr/sbin/{!s}".format(self.root_mount_point, self.executable) + return os.path.exists(bin_path) or os.path.exists(sbin_path) + + # The four abstract methods below are called in the order listed here. + # They must all be implemented by subclasses, but not all of them + # actually do something for all DMs. + + @abc.abstractmethod + def basic_setup(self): + """ + Do basic setup (e.g. users, groups, directory creation) for this DM. + """ + # Some implementations do nothing + + @abc.abstractmethod + def desktop_environment_setup(self, desktop_environment): + """ + Configure the given @p desktop_environment as the default one, in + the configuration files for this DM. + """ + # Many implementations do nothing + + @abc.abstractmethod + def greeter_setup(self): + """ + Additional setup for the greeter. + """ + # Most implementations do nothing + + @abc.abstractmethod + def set_autologin(self, username, do_autologin, default_desktop_environment): + """ + Configure the DM inside the given @p root_mount_point with + autologin (if @p do_autologin is True) for the given @p username. + If the DM supports it, set the default DE to @p default_desktop_environment + as well. + """ + + +class DMmdm(DisplayManager): + name = "mdm" + executable = "mdm" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with MDM as Desktop Manager + mdm_conf_path = os.path.join(self.root_mount_point, "etc/mdm/custom.conf") + + if os.path.exists(mdm_conf_path): + with open(mdm_conf_path, 'r') as mdm_conf: + text = mdm_conf.readlines() + + with open(mdm_conf_path, 'w') as mdm_conf: + for line in text: + if 'AutomaticLogin=' in line: + line = "" + if 'AutomaticLoginEnable=True' in line: + line = "" + if '[daemon]' in line: + if do_autologin: + line = ( + "[daemon]\n" + "AutomaticLogin={!s}\n" + "AutomaticLoginEnable=True\n".format(username) + ) + else: + line = ( + "[daemon]\n" + "AutomaticLoginEnable=False\n" + ) + + mdm_conf.write(line) + else: + with open(mdm_conf_path, 'w') as mdm_conf: + mdm_conf.write( + '# Calamares - Configure automatic login for user\n' + ) + mdm_conf.write('[daemon]\n') + + if do_autologin: + mdm_conf.write("AutomaticLogin={!s}\n".format(username)) + mdm_conf.write('AutomaticLoginEnable=True\n') + else: + mdm_conf.write('AutomaticLoginEnable=False\n') + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', 'mdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '-g', '128', 'mdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', 'mdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', + '-c', '"Linux Mint Display Manager"', + '-u', '128', + '-g', 'mdm', + '-d', '/var/lib/mdm', + '-s', '/usr/bin/nologin', + 'mdm' + ] + ) + + libcalamares.utils.target_env_call( + ['passwd', '-l', 'mdm'] + ) + libcalamares.utils.target_env_call( + ['chown', 'root:mdm', '/var/lib/mdm'] + ) + libcalamares.utils.target_env_call( + ['chmod', '1770', '/var/lib/mdm'] + ) + + def desktop_environment_setup(self, default_desktop_environment): + os.system( + "sed -i \"s|default.desktop|{!s}.desktop|g\" " + "{!s}/etc/mdm/custom.conf".format( + default_desktop_environment.desktop_file, + self.root_mount_point + ) + ) + + def greeter_setup(self): + pass + + +class DMgdm(DisplayManager): + name = "gdm" + executable = "gdm" + config = None # Set by have_dm() + + def have_dm(self): + """ + GDM exists with different executable names, so search + for one of them and use it. + """ + candidates = ( + ( "gdm", "etc/gdm/custom.conf" ), + ( "gdm3", "etc/gdm3/daemon.conf" ), + ( "gdm3", "etc/gdm3/custom.conf" ), + ) + + def have_executable(executable : str): + bin_path = "{!s}/usr/bin/{!s}".format(self.root_mount_point, executable) + sbin_path = "{!s}/usr/sbin/{!s}".format(self.root_mount_point, executable) + return os.path.exists(bin_path) or os.path.exists(sbin_path) + + def have_config(config : str): + config_path = "{!s}/{!s}".format(self.root_mount_point, config) + return os.path.exists(config_path) + + # Look for an existing configuration file as a hint, then + # keep the found-executable name and config around for later. + for executable, config in candidates: + if have_config(config) and have_executable(executable): + self.executable = executable + self.config = config + return True + for executable, config in candidates: + if have_executable(executable): + self.executable = executable + self.config = config + return True + + return False + + def set_autologin(self, username, do_autologin, default_desktop_environment): + if self.config is None: + raise ValueError( "No config file for GDM has been set." ) + + # Systems with GDM as Desktop Manager + gdm_conf_path = os.path.join(self.root_mount_point, self.config) + + if os.path.exists(gdm_conf_path): + with open(gdm_conf_path, 'r') as gdm_conf: + text = gdm_conf.readlines() + + with open(gdm_conf_path, 'w') as gdm_conf: + for line in text: + if 'AutomaticLogin=' in line: + line = "" + if 'AutomaticLoginEnable=True' in line: + line = "" + if '[daemon]' in line: + if do_autologin: + line = ( + "[daemon]\n" + "AutomaticLogin={!s}\n" + "AutomaticLoginEnable=True\n".format(username) + ) + else: + line = "[daemon]\nAutomaticLoginEnable=False\n" + + gdm_conf.write(line) + else: + with open(gdm_conf_path, 'w') as gdm_conf: + gdm_conf.write( + '# Calamares - Enable automatic login for user\n' + ) + gdm_conf.write('[daemon]\n') + + if do_autologin: + gdm_conf.write("AutomaticLogin={!s}\n".format(username)) + gdm_conf.write('AutomaticLoginEnable=True\n') + else: + gdm_conf.write('AutomaticLoginEnable=False\n') + + if (do_autologin): + accountservice_dir = "{!s}/var/lib/AccountsService/users".format( + self.root_mount_point + ) + userfile_path = "{!s}/{!s}".format(accountservice_dir, username) + if os.path.exists(accountservice_dir): + with open(userfile_path, "w") as userfile: + userfile.write("[User]\n") + + if default_desktop_environment is not None: + userfile.write("XSession={!s}\n".format( + default_desktop_environment.desktop_file)) + + userfile.write("Icon=\n") + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', 'gdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '-g', '120', 'gdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', 'gdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', + '-c', '"Gnome Display Manager"', + '-u', '120', + '-g', 'gdm', + '-d', '/var/lib/gdm', + '-s', '/usr/bin/nologin', + 'gdm' + ] + ) + + libcalamares.utils.target_env_call( + ['passwd', '-l', 'gdm'] + ) + libcalamares.utils.target_env_call( + ['chown', '-R', 'gdm:gdm', '/var/lib/gdm'] + ) + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + +class DMlxdm(DisplayManager): + name = "lxdm" + executable = "lxdm" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with LXDM as Desktop Manager + lxdm_conf_path = os.path.join(self.root_mount_point, "etc/lxdm/lxdm.conf") + text = [] + + if os.path.exists(lxdm_conf_path): + with open(lxdm_conf_path, 'r') as lxdm_conf: + text = lxdm_conf.readlines() + + with open(lxdm_conf_path, 'w') as lxdm_conf: + for line in text: + if 'autologin=' in line: + if do_autologin: + line = "autologin={!s}\n".format(username) + else: + line = "# autologin=\n" + + lxdm_conf.write(line) + else: + return ( + _("Cannot write LXDM configuration file"), + _("LXDM config file {!s} does not exist").format(lxdm_conf_path) + ) + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', 'lxdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '--system', 'lxdm'] + ) + + libcalamares.utils.target_env_call( + ['chgrp', '-R', 'lxdm', '/var/lib/lxdm'] + ) + libcalamares.utils.target_env_call( + ['chgrp', 'lxdm', '/etc/lxdm/lxdm.conf'] + ) + libcalamares.utils.target_env_call( + ['chmod', '+r', '/etc/lxdm/lxdm.conf'] + ) + + def desktop_environment_setup(self, default_desktop_environment): + os.system( + "sed -i -e \"s|^.*session=.*|session={!s}|\" " + "{!s}/etc/lxdm/lxdm.conf".format( + default_desktop_environment.executable, + self.root_mount_point + ) + ) + + def greeter_setup(self): + pass + + +class DMlightdm(DisplayManager): + name = "lightdm" + executable = "lightdm" + + # Can be overridden in the .conf file. With no value it won't match any + # desktop file in the xgreeters directory and instead we end up picking + # the alphabetically first file there. + preferred_greeters = [] + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with LightDM as Desktop Manager + # Ideally, we should use configparser for the ini conf file, + # but we just do a simple text replacement for now, as it + # worksforme(tm) + lightdm_conf_path = os.path.join( + self.root_mount_point, "etc/lightdm/lightdm.conf" + ) + text = [] + addseat = False + loopcount = 0 + + if os.path.exists(lightdm_conf_path): + with open(lightdm_conf_path, 'r') as lightdm_conf: + text = lightdm_conf.readlines() + # Check to make sure [SeatDefaults] or [Seat:*] is in the config, + # otherwise we'll risk malforming the config + addseat = '[SeatDefaults]' not in text and '[Seat:*]' not in text + + with open(lightdm_conf_path, 'w') as lightdm_conf: + if addseat: + # Prepend Seat line to start of file rather than leaving it without one + # This keeps the config from being malformed for LightDM + text = ["[Seat:*]\n"] + text + for line in text: + if 'autologin-user=' in line: + if do_autologin: + line = "autologin-user={!s}\n".format(username) + else: + line = "#autologin-user=\n" + + lightdm_conf.write(line) + else: + try: + # Create a new lightdm.conf file; this is documented to be + # read last, after everything in lightdm.conf.d/ + with open(lightdm_conf_path, 'w') as lightdm_conf: + if do_autologin: + lightdm_conf.write( + "[Seat:*]\nautologin-user={!s}\n".format(username)) + else: + lightdm_conf.write( + "[Seat:*]\n#autologin-user=\n") + except FileNotFoundError: + return ( + _("Cannot write LightDM configuration file"), + _("LightDM config file {!s} does not exist").format(lightdm_conf_path) + ) + + def basic_setup(self): + libcalamares.utils.target_env_call( + ['mkdir', '-p', '/run/lightdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'group', 'lightdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', '-g', '620', 'lightdm'] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', 'lightdm'] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', '-c', + '"LightDM Display Manager"', + '-u', '620', + '-g', 'lightdm', + '-d', '/var/run/lightdm', + '-s', '/usr/bin/nologin', + 'lightdm' + ] + ) + + libcalamares.utils.target_env_call(['passwd', '-l', 'lightdm']) + libcalamares.utils.target_env_call(['chown', '-R', 'lightdm:lightdm', '/run/lightdm']) + libcalamares.utils.target_env_call(['chmod', '+r' '/etc/lightdm/lightdm.conf']) + + def desktop_environment_setup(self, default_desktop_environment): + os.system( + "sed -i -e \"s/^.*user-session=.*/user-session={!s}/\" " + "{!s}/etc/lightdm/lightdm.conf".format( + default_desktop_environment.desktop_file, + self.root_mount_point + ) + ) + + def find_preferred_greeter(self): + """ + On Debian, lightdm-greeter.desktop is typically a symlink managed + by update-alternatives pointing to /etc/alternatives/lightdm-greeter + which is also a symlink to a real .desktop file back in /usr/share/xgreeters/ + + Returns a path *into the mounted target* of the preferred greeter -- usually + a .desktop file that specifies where the actual executable is. May return + None to indicate nothing-was-found. + """ + greeters_dir = "usr/share/xgreeters" + greeters_target_path = os.path.join(self.root_mount_point, greeters_dir) + available_names = os.listdir(greeters_target_path) + available_names.sort() + desktop_names = [n for n in self.preferred_greeters if n in available_names] # Preferred ones + if desktop_names: + return desktop_names[0] + desktop_names = [n for n in available_names if n.endswith(".desktop")] # .. otherwise any .desktop + if desktop_names: + return desktop_names[0] + return None + + def greeter_setup(self): + lightdm_conf_path = os.path.join(self.root_mount_point, "etc/lightdm/lightdm.conf") + greeter_name = self.find_preferred_greeter() + + if greeter_name is not None: + greeter = os.path.basename(greeter_name) # Follow symlinks, hope they are not absolute + if greeter.endswith('.desktop'): + greeter = greeter[:-8] # Remove ".desktop" from end + + libcalamares.utils.debug("found greeter {!s}".format(greeter)) + os.system( + "sed -i -e \"s/^.*greeter-session=.*" + "/greeter-session={!s}/\" {!s}".format( + greeter, + lightdm_conf_path + ) + ) + libcalamares.utils.debug("{!s} configured as greeter.".format(greeter)) + else: + libcalamares.utils.error("No greeter found at all, preferred {!s}".format(self.preferred_greeters)) + return ( + _("Cannot configure LightDM"), + _("No LightDM greeter installed.") + ) + + +class DMslim(DisplayManager): + name = "slim" + executable = "slim" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + # Systems with Slim as Desktop Manager + slim_conf_path = os.path.join(self.root_mount_point, "etc/slim.conf") + text = [] + + if os.path.exists(slim_conf_path): + with open(slim_conf_path, 'r') as slim_conf: + text = slim_conf.readlines() + + with open(slim_conf_path, 'w') as slim_conf: + for line in text: + if 'auto_login' in line: + if do_autologin: + line = 'auto_login yes\n' + else: + line = 'auto_login no\n' + + if do_autologin and 'default_user' in line: + line = "default_user {!s}\n".format(username) + + slim_conf.write(line) + else: + return ( + _("Cannot write SLIM configuration file"), + _("SLIM config file {!s} does not exist").format(slim_conf_path) + ) + + + def basic_setup(self): + pass + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + +class DMsddm(DisplayManager): + name = "sddm" + executable = "sddm" + + configuration_file = "/etc/sddm.conf" + + def set_autologin(self, username, do_autologin, default_desktop_environment): + import configparser + + # Systems with Sddm as Desktop Manager + sddm_conf_path = os.path.join(self.root_mount_point, self.configuration_file.lstrip('/')) + + sddm_config = configparser.ConfigParser(strict=False) + # Make everything case sensitive + sddm_config.optionxform = str + + if os.path.isfile(sddm_conf_path): + sddm_config.read(sddm_conf_path) + + if 'Autologin' not in sddm_config: + sddm_config.add_section('Autologin') + + if do_autologin: + sddm_config.set('Autologin', 'User', username) + elif sddm_config.has_option('Autologin', 'User'): + sddm_config.remove_option('Autologin', 'User') + + if default_desktop_environment is not None: + sddm_config.set( + 'Autologin', + 'Session', + default_desktop_environment.desktop_file + ) + + with open(sddm_conf_path, 'w') as sddm_config_file: + sddm_config.write(sddm_config_file, space_around_delimiters=False) + + + def basic_setup(self): + pass + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + +class DMgreetd(DisplayManager): + name = "greetd" + executable = "greetd" + greeter_user = "greeter" + greeter_group = "greetd" + greeter_css_location = None + config_data = {} + + def os_path(self, path): + return os.path.join(self.root_mount_point, path) + + def config_path(self): + return self.os_path("etc/greetd/config.toml") + + def environments_path(self): + return self.os_path("etc/greetd/environments") + + def config_load(self): + import toml + + if (os.path.exists(self.config_path())): + with open(self.config_path(), "r") as f: + self.config_data = toml.load(f) + + self.config_data['terminal'] = dict(vt = "next") + + default_session_group = self.config_data.get('default_session', None) + if not default_session_group: + self.config_data['default_session'] = {} + + self.config_data['default_session']['user'] = self.greeter_user + + return self.config_data + + def config_write(self): + import toml + with open(self.config_path(), "w") as f: + toml.dump(self.config_data, f) + + def basic_setup(self): + if libcalamares.utils.target_env_call( + ['getent', 'group', self.greeter_group] + ) != 0: + libcalamares.utils.target_env_call( + ['groupadd', self.greeter_group] + ) + + if libcalamares.utils.target_env_call( + ['getent', 'passwd', self.greeter_user] + ) != 0: + libcalamares.utils.target_env_call( + ['useradd', + '-c', '"Greeter User"', + '-g', self.greeter_group, + '-s', '/bin/bash', + self.greeter_user + ] + ) + + def desktop_environment_setup(self, default_desktop_environment): + with open(self.environments_path(), 'w') as envs_file: + envs_file.write(default_desktop_environment.executable) + envs_file.write("\n") + + def greeter_setup(self): + pass + + def set_autologin(self, username, do_autologin, default_desktop_environment): + self.config_load() + + de_command = default_desktop_environment.executable + if os.path.exists(self.os_path("usr/bin/gtkgreet")) and os.path.exists(self.os_path("usr/bin/cage")): + self.config_data['default_session']['command'] = "cage -d -s -- gtkgreet" + if self.greeter_css_location: + self.config_data['default_session']['command'] += f" -s {self.greeter_css_location}" + elif os.path.exists(self.os_path("usr/bin/tuigreet")): + tuigreet_base_cmd = "tuigreet --remember --time --issue --asterisks --cmd " + self.config_data['default_session']['command'] = tuigreet_base_cmd + de_command + elif os.path.exists(self.os_path("usr/bin/ddlm")): + self.config_data['default_session']['command'] = "ddlm --target " + de_command + else: + self.config_data['default_session']['command'] = "agreety --cmd " + de_command + + if do_autologin: + # Log in as user, with given DE + self.config_data['initial_session'] = dict(command = de_command, user = username) + elif 'initial_session' in self.config_data: + # No autologin, remove any autologin that was copied from the live ISO + del self.config_data['initial_session'] + + self.config_write() + + +class DMsysconfig(DisplayManager): + name = "sysconfig" + executable = None + + def set_autologin(self, username, do_autologin, default_desktop_environment): + dmauto = "DISPLAYMANAGER_AUTOLOGIN" + + os.system( + "sed -i -e 's|^{!s}=.*|{!s}=\"{!s}\"|' " + "{!s}/etc/sysconfig/displaymanager".format( + dmauto, dmauto, + username if do_autologin else "", + self.root_mount_point + ) + ) + + + def basic_setup(self): + pass + + def desktop_environment_setup(self, desktop_environment): + pass + + def greeter_setup(self): + pass + + # For openSUSE-derivatives, there is only sysconfig to configure, + # and no special DM configuration for it. Instead, check that + # sysconfig is available in the target. + def have_dm(self): + config = "{!s}/etc/sysconfig/displaymanager".format(self.root_mount_point) + return os.path.exists(config) + + +# Collect all the subclasses of DisplayManager defined above, +# and index them based on the name property of each class. +display_managers = [ + (c.name, c) + for c in globals().values() + if type(c) is abc.ABCMeta and issubclass(c, DisplayManager) and c.name +] + + +def run(): + """ + Configure display managers. + + We acquire a list of displaymanagers, either from config or (overridden) + from globalstorage. This module will try to set up (including autologin) + all the displaymanagers in the list, in that specific order. Most distros + will probably only ship one displaymanager. + If a displaymanager is in the list but not installed, a debugging message + is printed and the entry ignored. + """ + # Get configuration settings for display managers + displaymanagers = None + if "displaymanagers" in libcalamares.job.configuration: + displaymanagers = libcalamares.job.configuration["displaymanagers"] + + if libcalamares.globalstorage.contains("displayManagers"): + displaymanagers = libcalamares.globalstorage.value("displayManagers") + + if ("sysconfigSetup" in libcalamares.job.configuration + and libcalamares.job.configuration["sysconfigSetup"]): + displaymanagers = ["sysconfig"] + + if not displaymanagers: + return ( + _("No display managers selected for the displaymanager module."), + _("The displaymanagers list is empty or undefined in both " + "globalstorage and displaymanager.conf.") + ) + + # Get instances that are actually installed + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + dm_impl = [] + dm_names = displaymanagers[:] + for dm in dm_names: + # Find the implementation class + dm_instance = None + impl = [ cls for name, cls in display_managers if name == dm ] + if len(impl) == 1: + dm_instance = impl[0](root_mount_point) + if dm_instance.have_dm(): + dm_impl.append(dm_instance) + else: + dm_instance = None + else: + libcalamares.utils.debug("{!s} has {!s} implementation classes.".format(dm, len(impl))) + + if dm_instance is None: + libcalamares.utils.debug("{!s} selected but not installed".format(dm)) + if dm in displaymanagers: + displaymanagers.remove(dm) + + if not dm_impl: + libcalamares.utils.warning( + "No display managers selected for the displaymanager module. " + "The list is empty after checking for installed display managers." + ) + return None + + # Pick up remaining settings + if "defaultDesktopEnvironment" in libcalamares.job.configuration: + entry = libcalamares.job.configuration["defaultDesktopEnvironment"] + default_desktop_environment = DesktopEnvironment( + entry["executable"], entry["desktopFile"] + ) + # Adjust if executable is bad, but desktopFile isn't. + if not default_desktop_environment.update_from_desktop_file(root_mount_point): + libcalamares.utils.warning( + "The configured default desktop environment, {!s}, " + "can not be found.".format(default_desktop_environment.desktop_file)) + else: + default_desktop_environment = find_desktop_environment( + root_mount_point + ) + + if "basicSetup" in libcalamares.job.configuration: + enable_basic_setup = libcalamares.job.configuration["basicSetup"] + else: + enable_basic_setup = False + + username = libcalamares.globalstorage.value("autoLoginUser") + if username is not None: + do_autologin = True + libcalamares.utils.debug("Setting up autologin for user {!s}.".format(username)) + else: + do_autologin = False + libcalamares.utils.debug("Unsetting autologin.") + + libcalamares.globalstorage.insert("displayManagers", displaymanagers) + + # Do the actual configuration and collect messages + dm_setup_message = [] + for dm in dm_impl: + dm_specific_configuration = libcalamares.job.configuration.get(dm.name, None) + if dm_specific_configuration and isinstance(dm_specific_configuration, dict): + for k, v in dm_specific_configuration.items(): + if hasattr(dm, k): + setattr(dm, k, v) + dm_message = None + if enable_basic_setup: + dm_message = dm.basic_setup() + if default_desktop_environment is not None and dm_message is None: + dm_message = dm.desktop_environment_setup(default_desktop_environment) + if dm_message is None: + dm_message = dm.greeter_setup() + if dm_message is None: + dm_message = dm.set_autologin(username, do_autologin, default_desktop_environment) + + if dm_message is not None: + dm_setup_message.append("{!s}: {!s}".format(*dm_message)) + + if dm_setup_message: + return ( + _("Display manager configuration was incomplete"), + "\n".join(dm_setup_message) + ) diff --git a/calamares/src/modules/displaymanager/module.desc b/calamares/src/modules/displaymanager/module.desc new file mode 100644 index 0000000..a589418 --- /dev/null +++ b/calamares/src/modules/displaymanager/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "displaymanager" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/displaymanager/tests/1.global b/calamares/src/modules/displaymanager/tests/1.global new file mode 100644 index 0000000..ee06ccf --- /dev/null +++ b/calamares/src/modules/displaymanager/tests/1.global @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +rootMountPoint: /tmp diff --git a/calamares/src/modules/displaymanager/tests/CMakeTests.txt b/calamares/src/modules/displaymanager/tests/CMakeTests.txt new file mode 100644 index 0000000..22195f2 --- /dev/null +++ b/calamares/src/modules/displaymanager/tests/CMakeTests.txt @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# We have tests to load (some) of the DMs specifically, to test their +# configuration code. Those tests conventionally live in Python +# files here in the tests/ directory. Add them. +foreach(_dmname greetd sddm gdm) + add_test( + NAME configure-displaymanager-${_dmname} + COMMAND env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-dm-${_dmname}.py + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + ) +endforeach() diff --git a/calamares/src/modules/displaymanager/tests/test-dm-gdm.py b/calamares/src/modules/displaymanager/tests/test-dm-gdm.py new file mode 100644 index 0000000..4856e8a --- /dev/null +++ b/calamares/src/modules/displaymanager/tests/test-dm-gdm.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.displaymanager import main +default_desktop_environment = main.DesktopEnvironment("startplasma-x11", "kde-plasma.desktop") + +import os +import tempfile +with tempfile.TemporaryDirectory(prefix="calamares-gdm") as tempdir: + os.makedirs(tempdir + "/usr/bin") + os.makedirs(tempdir + "/etc/gdm3") + with open(tempdir + "/usr/bin/gdm3", "w") as f: + f.write("#! /bin/sh\n:\n") + # Specific DM test + d = main.DMgdm(tempdir) + assert(d.have_dm()) + d.set_autologin("d", True, default_desktop_environment) + # .. and again (this time checks load/save) + d.set_autologin("d", True, default_desktop_environment) + d.set_autologin("d", True, default_desktop_environment) diff --git a/calamares/src/modules/displaymanager/tests/test-dm-greetd.py b/calamares/src/modules/displaymanager/tests/test-dm-greetd.py new file mode 100644 index 0000000..e2682af --- /dev/null +++ b/calamares/src/modules/displaymanager/tests/test-dm-greetd.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.displaymanager import main +default_desktop_environment = main.DesktopEnvironment("startplasma-x11", "kde-plasma.desktop") + +import os +os.makedirs("/tmp/etc/greetd/", exist_ok=True) +try: + os.remove("/tmp/etc/greetd/config.toml") +except FileNotFoundError as e: + pass + +try: + import toml +except ImportError: + # This is a failure of the test-environment. + import sys + print("Can't find module toml.", file=sys.stderr) + sys.exit(0) + +# Specific DM test +d = main.DMgreetd("/tmp") +d.set_autologin("d", True, default_desktop_environment) +# .. and again (this time checks load/save) +d.set_autologin("d", True, default_desktop_environment) +d.set_autologin("d", True, default_desktop_environment) diff --git a/calamares/src/modules/displaymanager/tests/test-dm-sddm.py b/calamares/src/modules/displaymanager/tests/test-dm-sddm.py new file mode 100644 index 0000000..b5c3349 --- /dev/null +++ b/calamares/src/modules/displaymanager/tests/test-dm-sddm.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.displaymanager import main +default_desktop_environment = main.DesktopEnvironment("startplasma-x11", "kde-plasma.desktop") + +# Specific DM test +d = main.DMsddm("/tmp") +d.set_autologin("d", True, default_desktop_environment) +# .. and again (this time checks load/save) +d.set_autologin("d", True, default_desktop_environment) +d.set_autologin("d", True, default_desktop_environment) diff --git a/calamares/src/modules/dracut/dracut.conf b/calamares/src/modules/dracut/dracut.conf new file mode 100644 index 0000000..190933b --- /dev/null +++ b/calamares/src/modules/dracut/dracut.conf @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Run dracut(8) with an optional kernel name +--- +# Dracut defaults to setting initramfs-.img +# If you want to specify another filename for the resulting image, +# set a custom name, including the path +# +initramfsName: /boot/initramfs-freebsd.img + +# Optional: define a list of strings to be passed as arguments to Dracut +# By default, -f is always included +options: [ "-f" ] diff --git a/calamares/src/modules/dracut/dracut.schema.yaml b/calamares/src/modules/dracut/dracut.schema.yaml new file mode 100644 index 0000000..de1114c --- /dev/null +++ b/calamares/src/modules/dracut/dracut.schema.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2022 Anke Boersma +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/dracut +additionalProperties: false +type: object +properties: + initramfsName: { type: string } + options: { type: array, items: { type: string } } diff --git a/calamares/src/modules/dracut/main.py b/calamares/src/modules/dracut/main.py new file mode 100644 index 0000000..9e2bc31 --- /dev/null +++ b/calamares/src/modules/dracut/main.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014-2015 Philip Müller +# SPDX-FileCopyrightText: 2014 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-FileCopyrightText: 2022 Anke Boersma +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# +import subprocess + +import libcalamares +from libcalamares.utils import target_env_process_output + + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Creating initramfs with dracut.") + + +def run_dracut(): + """ + Creates initramfs, even when initramfs already exists. + + :return: + """ + # Fetch the job configuration + initramfs_name = libcalamares.job.configuration.get('initramfsName', None) + dracut_options = libcalamares.job.configuration.get('options', ['-f']) + + if initramfs_name: + dracut_options.append(initramfs_name) + + try: + target_env_process_output(['dracut'] + dracut_options) + except subprocess.CalledProcessError as cpe: + libcalamares.utils.warning(f"Dracut failed with output: {cpe.output}") + return cpe.returncode + + return 0 + + +def run(): + """ + Starts routine to create initramfs. It passes back the exit code + if it fails. + + :return: + """ + return_code = run_dracut() + if return_code != 0: + return (_("Failed to run dracut"), + _(f"Dracut failed to run on the target with return code: {return_code}")) diff --git a/calamares/src/modules/dracut/module.desc b/calamares/src/modules/dracut/module.desc new file mode 100644 index 0000000..9029bf6 --- /dev/null +++ b/calamares/src/modules/dracut/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "dracut" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/dracutlukscfg/CMakeLists.txt b/calamares/src/modules/dracutlukscfg/CMakeLists.txt new file mode 100644 index 0000000..85efccc --- /dev/null +++ b/calamares/src/modules/dracutlukscfg/CMakeLists.txt @@ -0,0 +1,13 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(dracutlukscfg + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + DracutLuksCfgJob.cpp + SHARED_LIB + NO_CONFIG +) diff --git a/calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.cpp b/calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.cpp new file mode 100644 index 0000000..6bc87e2 --- /dev/null +++ b/calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.cpp @@ -0,0 +1,159 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "DracutLuksCfgJob.h" + +#include +#include +#include +#include + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "utils/Logger.h" + +static const QLatin1String CONFIG_FILE( "/etc/dracut.conf.d/calamares-luks.conf" ); + +static const char CONFIG_FILE_HEADER[] + = "# Configuration file automatically written by the Calamares system installer\n" + "# (This file is written once at install time and should be safe to edit.)\n" + "# Enables support for LUKS full disk encryption with single sign on from GRUB.\n" + "\n"; + +static const char CONFIG_FILE_CRYPTTAB_KEYFILE_LINE[] + = "# force installing /etc/crypttab even if hostonly=\"no\", install the keyfile\n" + "install_items+=\" /etc/crypttab /crypto_keyfile.bin \"\n"; + +static const char CONFIG_FILE_CRYPTTAB_LINE[] = "# force installing /etc/crypttab even if hostonly=\"no\"\n" + "install_items+=\" /etc/crypttab \"\n"; + +static const QLatin1String + CONFIG_FILE_SWAPLINE( "# enable automatic resume from swap\nadd_device+=\" /dev/disk/by-uuid/%1 \"\n" ); + +static QString +rootMountPoint() +{ + Calamares::GlobalStorage* globalStorage = Calamares::JobQueue::instance()->globalStorage(); + return globalStorage->value( QStringLiteral( "rootMountPoint" ) ).toString(); +} + +static QVariantList +partitions() +{ + Calamares::GlobalStorage* globalStorage = Calamares::JobQueue::instance()->globalStorage(); + return globalStorage->value( QStringLiteral( "partitions" ) ).toList(); +} + +static bool +isRootEncrypted() +{ + const QVariantList partitions = ::partitions(); + for ( const QVariant& partition : partitions ) + { + QVariantMap partitionMap = partition.toMap(); + QString mountPoint = partitionMap.value( QStringLiteral( "mountPoint" ) ).toString(); + if ( mountPoint == QStringLiteral( "/" ) ) + { + return partitionMap.contains( QStringLiteral( "luksMapperName" ) ); + } + } + return false; +} + +static bool +hasUnencryptedSeparateBoot() +{ + const QVariantList partitions = ::partitions(); + for ( const QVariant& partition : partitions ) + { + QVariantMap partitionMap = partition.toMap(); + QString mountPoint = partitionMap.value( QStringLiteral( "mountPoint" ) ).toString(); + if ( mountPoint == QStringLiteral( "/boot" ) ) + { + return !partitionMap.contains( QStringLiteral( "luksMapperName" ) ); + } + } + return false; +} + +static QString +swapOuterUuid() +{ + const QVariantList partitions = ::partitions(); + for ( const QVariant& partition : partitions ) + { + QVariantMap partitionMap = partition.toMap(); + QString fsType = partitionMap.value( QStringLiteral( "fs" ) ).toString(); + if ( fsType == QStringLiteral( "linuxswap" ) && partitionMap.contains( QStringLiteral( "luksMapperName" ) ) ) + { + return partitionMap.value( QStringLiteral( "luksUuid" ) ).toString(); + } + } + return QString(); +} + +DracutLuksCfgJob::DracutLuksCfgJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + + +DracutLuksCfgJob::~DracutLuksCfgJob() {} + + +QString +DracutLuksCfgJob::prettyName() const +{ + if ( isRootEncrypted() ) + { + return tr( "Writing LUKS configuration for Dracut to %1…", "@status" ).arg( CONFIG_FILE ); + } + else + { + return tr( "Skipping writing LUKS configuration for Dracut: \"/\" partition is not encrypted", "@info" ); + } +} + + +Calamares::JobResult +DracutLuksCfgJob::exec() +{ + if ( isRootEncrypted() ) + { + const QString realConfigFilePath = rootMountPoint() + CONFIG_FILE; + cDebug() << "[DRACUTLUKSCFG]: Writing" << realConfigFilePath; + QDir( QStringLiteral( "/" ) ).mkpath( QFileInfo( realConfigFilePath ).absolutePath() ); + QFile configFile( realConfigFilePath ); + if ( !configFile.open( QIODevice::WriteOnly | QIODevice::Text ) ) + { + cDebug() << "[DRACUTLUKSCFG]: Failed to open" << realConfigFilePath; + return Calamares::JobResult::error( tr( "Failed to open %1", "@error" ).arg( realConfigFilePath ) ); + } + QTextStream outStream( &configFile ); + outStream << CONFIG_FILE_HEADER + << ( hasUnencryptedSeparateBoot() ? CONFIG_FILE_CRYPTTAB_LINE : CONFIG_FILE_CRYPTTAB_KEYFILE_LINE ); + const QString swapOuterUuid = ::swapOuterUuid(); + if ( !swapOuterUuid.isEmpty() ) + { + cDebug() << "[DRACUTLUKSCFG]: Swap outer UUID" << swapOuterUuid; + outStream << QString( CONFIG_FILE_SWAPLINE ).arg( swapOuterUuid ).toLatin1(); + } + cDebug() << "[DRACUTLUKSCFG]: Wrote config to" << realConfigFilePath; + } + else + { + cDebug() << "[DRACUTLUKSCFG]: / not encrypted, skipping"; + } + + return Calamares::JobResult::ok(); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( DracutLuksCfgJobFactory, registerPlugin< DracutLuksCfgJob >(); ) diff --git a/calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.h b/calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.h new file mode 100644 index 0000000..33029f9 --- /dev/null +++ b/calamares/src/modules/dracutlukscfg/DracutLuksCfgJob.h @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef DRACUTLUKSCFGJOB_H +#define DRACUTLUKSCFGJOB_H + +#include +#include + +#include "CppJob.h" + +#include "utils/PluginFactory.h" + +#include "DllMacro.h" + +class PLUGINDLLEXPORT DracutLuksCfgJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit DracutLuksCfgJob( QObject* parent = nullptr ); + ~DracutLuksCfgJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + +private: +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( DracutLuksCfgJobFactory ) + +#endif // DRACUTLUKSCFGJOB_H diff --git a/calamares/src/modules/dummycpp/CMakeLists.txt b/calamares/src/modules/dummycpp/CMakeLists.txt new file mode 100644 index 0000000..b822a03 --- /dev/null +++ b/calamares/src/modules/dummycpp/CMakeLists.txt @@ -0,0 +1,12 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(dummycpp + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + DummyCppJob.cpp + SHARED_LIB +) diff --git a/calamares/src/modules/dummycpp/DummyCppJob.cpp b/calamares/src/modules/dummycpp/DummyCppJob.cpp new file mode 100644 index 0000000..0e709d3 --- /dev/null +++ b/calamares/src/modules/dummycpp/DummyCppJob.cpp @@ -0,0 +1,154 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac (original dummypython code) + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "DummyCppJob.h" + +#include +#include + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" +#include "utils/System.h" + +DummyCppJob::DummyCppJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +DummyCppJob::~DummyCppJob() {} + +QString +DummyCppJob::prettyName() const +{ + return tr( "Performing dummy C++ job…", "@status" ); +} + +static QString variantListToString( const QVariantList& variantList ); +static QString variantMapToString( const QVariantMap& variantMap ); +static QString variantHashToString( const QVariantHash& variantHash ); + +static QString +variantToString( const QVariant& variant ) +{ + if ( Calamares::typeOf( variant ) == Calamares::MapVariantType ) + { + return variantMapToString( variant.toMap() ); + } + else if ( Calamares::typeOf( variant ) == Calamares::HashVariantType ) + { + return variantHashToString( variant.toHash() ); + } + else if ( ( Calamares::typeOf( variant ) == Calamares::ListVariantType ) + || ( Calamares::typeOf( variant ) == Calamares::StringListVariantType ) ) + { + return variantListToString( variant.toList() ); + } + else + { + return variant.toString(); + } +} + +static QString +variantListToString( const QVariantList& variantList ) +{ + QStringList result; + for ( const QVariant& variant : variantList ) + { + result.append( variantToString( variant ) ); + } + return '{' + result.join( ',' ) + '}'; +} + +static QString +variantMapToString( const QVariantMap& variantMap ) +{ + QStringList result; + for ( auto it = variantMap.constBegin(); it != variantMap.constEnd(); ++it ) + { + result.append( it.key() + '=' + variantToString( it.value() ) ); + } + return '[' + result.join( ',' ) + ']'; +} + +static QString +variantHashToString( const QVariantHash& variantHash ) +{ + QStringList result; + for ( auto it = variantHash.constBegin(); it != variantHash.constEnd(); ++it ) + { + result.append( it.key() + '=' + variantToString( it.value() ) ); + } + return '<' + result.join( ',' ) + '>'; +} + +Calamares::JobResult +DummyCppJob::exec() +{ + // Ported from dummypython + Calamares::System::runCommand( Calamares::System::RunLocation::RunInHost, + QStringList() << "/bin/sh" + << "-c" + << "touch ~/calamares-dummycpp" ); + QString accumulator = QDateTime::currentDateTimeUtc().toString( Qt::ISODate ) + '\n'; + accumulator += QStringLiteral( "Calamares version: " ) + CALAMARES_VERSION_SHORT + '\n'; + accumulator += QStringLiteral( "This job's name: " ) + prettyName() + '\n'; + accumulator += QStringLiteral( "Configuration map: %1\n" ).arg( variantMapToString( m_configurationMap ) ); + accumulator += QStringLiteral( " *** globalstorage test ***\n" ); + Calamares::GlobalStorage* globalStorage = Calamares::JobQueue::instance()->globalStorage(); + accumulator += QStringLiteral( "lala: " ) + + ( globalStorage->contains( "lala" ) ? QStringLiteral( "true" ) : QStringLiteral( "false" ) ) + '\n'; + accumulator += QStringLiteral( "foo: " ) + + ( globalStorage->contains( "foo" ) ? QStringLiteral( "true" ) : QStringLiteral( "false" ) ) + '\n'; + accumulator += QStringLiteral( "count: " ) + QString::number( globalStorage->count() ) + '\n'; + globalStorage->insert( "item2", "value2" ); + globalStorage->insert( "item3", 3 ); + accumulator += QStringLiteral( "keys: %1\n" ).arg( globalStorage->keys().join( ',' ) ); + accumulator += QStringLiteral( "remove: %1\n" ).arg( QString::number( globalStorage->remove( "item2" ) ) ); + accumulator += QStringLiteral( "values: %1 %2 %3\n" ) + .arg( globalStorage->value( "foo" ).toString(), + globalStorage->value( "item2" ).toString(), + globalStorage->value( "item3" ).toString() ); + + Q_EMIT progress( 0.1 ); + cDebug() << "[DUMMYCPP]: " << accumulator; + + globalStorage->debugDump(); + Q_EMIT progress( 0.5 ); + + QThread::sleep( 1 ); + Calamares::System::instance()->targetEnvCall( + QStringList { "ls" }, + QString(), + QString(), + std::chrono::seconds( 1 ) ); // Expect an error because of missing rootMountPoint + + for ( int i = 0; i < 1000000; ++i ) + { + Q_EMIT progress( qreal( i / 1000000.f ) ); + } + + QThread::sleep( 1 ); + + return Calamares::JobResult::ok(); +} + +void +DummyCppJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_configurationMap = configurationMap; +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( DummyCppJobFactory, registerPlugin< DummyCppJob >(); ) diff --git a/calamares/src/modules/dummycpp/DummyCppJob.h b/calamares/src/modules/dummycpp/DummyCppJob.h new file mode 100644 index 0000000..5271a73 --- /dev/null +++ b/calamares/src/modules/dummycpp/DummyCppJob.h @@ -0,0 +1,43 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Kevin Kofler + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef DUMMYCPPJOB_H +#define DUMMYCPPJOB_H + +#include +#include + +#include "CppJob.h" + +#include "utils/PluginFactory.h" + +#include "DllMacro.h" + +class PLUGINDLLEXPORT DummyCppJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit DummyCppJob( QObject* parent = nullptr ); + ~DummyCppJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QVariantMap m_configurationMap; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( DummyCppJobFactory ) + +#endif // DUMMYCPPJOB_H diff --git a/calamares/src/modules/dummycpp/dummycpp.conf b/calamares/src/modules/dummycpp/dummycpp.conf new file mode 100644 index 0000000..b00a428 --- /dev/null +++ b/calamares/src/modules/dummycpp/dummycpp.conf @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# This is a dummy (example) module for C++ Jobs. +# +# The code is the documentation for the configuration file. +--- +syntax: "YAML map of anything" +example: + whats_this: "module-specific configuration" + from_where: "dummycpp.conf" +a_list: + - "item1" + - "item2" + - "item3" + - "item4" +a_list_of_maps: + - name: "an Item" + contents: + - "an element" + - "another element" + - name: "another item" + contents: + - "not much" diff --git a/calamares/src/modules/dummycpp/module.desc b/calamares/src/modules/dummycpp/module.desc new file mode 100644 index 0000000..bc768a1 --- /dev/null +++ b/calamares/src/modules/dummycpp/module.desc @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# Module metadata file for dummycpp job +# +# The metadata for C++ (qtplugin) plugins is almost never interesting: +# the CMakeLists.txt should be using calamares_add_plugin() which will +# generate the metadata file during the build. Only C++ plugins that +# have strange settings should have a module.desc (non-C++ plugins, +# on the other hand, must have one, since they don't have CMakeLists.txt). +# +# Syntax is YAML 1.2 +# +# All four keys are mandatory. For C++ (qtplugin) modules, the interface +# value must be "qtplugin"; type is one of "job" or "view"; the name +# is the machine-identifier for the module and the load value should +# be the filename of the library that contains the implementation. +# +--- +type: "job" +name: "dummycpp" +interface: "qtplugin" +load: "libcalamares_job_dummycpp.so" diff --git a/calamares/src/modules/dummyprocess/module.desc b/calamares/src/modules/dummyprocess/module.desc new file mode 100644 index 0000000..a329b77 --- /dev/null +++ b/calamares/src/modules/dummyprocess/module.desc @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# Module metadata file for dummy process jobmodule +# Syntax is YAML 1.2 +--- +type: "job" +name: "dummyprocess" +interface: "process" +chroot: false +command: "/bin/sh -c \"sleep 5 ; touch ~/calamares-dummyprocess\"" +timeout: 8 diff --git a/calamares/src/modules/dummypython/dummypython.conf b/calamares/src/modules/dummypython/dummypython.conf new file mode 100644 index 0000000..6ea50c5 --- /dev/null +++ b/calamares/src/modules/dummypython/dummypython.conf @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# This is a dummy (example) module for a Python Job Module. +# +# The code is the documentation for the configuration file. +--- +syntax: "YAML map of anything" +example: + whats_this: "module-specific configuration" + from_where: "dummypython.conf" +a_list: + - "item1" + - "item2" + - "item3" + - "item4" +a_list_of_maps: + - name: "an Item" + contents: + - "an element" + - "another element" + - name: "another item" + contents: + - "not much" diff --git a/calamares/src/modules/dummypython/main.py b/calamares/src/modules/dummypython/main.py new file mode 100644 index 0000000..5ca3455 --- /dev/null +++ b/calamares/src/modules/dummypython/main.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +""" +=== Example Python jobmodule. + +A Python jobmodule is a Python program which imports libcalamares and +has a function run() as entry point. run() must return None if everything +went well, or a tuple (str,str) with an error message and description +if something went wrong. +""" + +import libcalamares +import os +from time import gmtime, strftime, sleep + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Dummy python job.") + +status = _("Dummy python step {}").format(0) + +def pretty_status_message(): + return status + +def run(): + """Dummy python job.""" + libcalamares.utils.debug(f"Calamares version: {libcalamares.VERSION} date: {strftime('%Y-%m-%d %H:%M:%S', gmtime())}") + libcalamares.utils.debug(f"Job name : {libcalamares.job.pretty_name}") + libcalamares.utils.debug(f"Job path : {libcalamares.job.working_path}") + + libcalamares.utils.debug(f"LocaleDir : {libcalamares.utils.gettext_path()}") + libcalamares.utils.debug(f"Languages : {libcalamares.utils.gettext_languages()}") + + os.system("/bin/sh -c \"touch ~/calamares-dummypython\"") + + libcalamares.utils.debug("*** JOB CONFIGURATION ***") + for k, v in libcalamares.job.configuration.items(): + libcalamares.utils.debug(f" {k}={v}") + + libcalamares.utils.debug("*** GLOBAL STORAGE ***") + for k in libcalamares.globalstorage.keys(): + libcalamares.utils.debug(f" {k}={libcalamares.globalstorage.value(k)}") + + libcalamares.utils.debug("*** GLOBAL STORAGE BOGUS KEYS ***") + # + # This is a demonstration of issue #2237, load this module + # with the dummypython/tests/1.global configuration, e.g. + # ./loadmodule -g ../src/modules/dummypython/tests/1.global dummypython + # in the build directory. + # + for k in ("nonexistent", "empty", "numeric", "boolvalue"): + if libcalamares.globalstorage.value(k) is None: + libcalamares.utils.debug(f"NONE {k}={libcalamares.globalstorage.value(k)}") + else: + libcalamares.utils.debug(f" {k}={libcalamares.globalstorage.value(k)}") + + libcalamares.utils.debug("*** GLOBAL STORAGE MODIFICATION ***") + libcalamares.globalstorage.insert("item2", "value2") + libcalamares.globalstorage.insert("item3", 3) + accumulator = "keys: {}\n".format(str(libcalamares.globalstorage.keys())) + libcalamares.utils.debug(accumulator) + + accumulator = "remove: {}\n".format( + str(libcalamares.globalstorage.remove("item2"))) + accumulator += "values: {} {} {}\n".format( + str(libcalamares.globalstorage.value("foo")), + str(libcalamares.globalstorage.value("item2")), + str(libcalamares.globalstorage.value("item3"))) + libcalamares.utils.debug(accumulator) + + libcalamares.utils.debug("*** ACTIVITY ***") + + # Expect error message that rootMountPoint is not set + libcalamares.utils.target_env_call(["ls"]) + libcalamares.utils.target_env_call("ls") + + # Expect error message can't chroot to /tmp + libcalamares.globalstorage.insert("rootMountPoint", "/tmp") + libcalamares.utils.target_env_call(["ls"]) + + sleep(1) + + million = 1000000 + for i in range(million): + libcalamares.job.setprogress(i / million) + + try: + configlist = list(libcalamares.job.configuration["a_list"]) + except KeyError: + configlist = ["no list"] + + global status + c = 1 + for k in configlist: + status = _("Dummy python step {}").format(str(c) + ":" + repr(k)) + libcalamares.utils.debug(_("Dummy python step {}").format(str(k))) + sleep(1) + libcalamares.job.setprogress(c * 1.0 / (len(configlist)+1)) + c += 1 + + sleep(3) + + # To indicate an error, return a tuple of: + # (message, detailed-error-message) + return None diff --git a/calamares/src/modules/dummypython/module.desc b/calamares/src/modules/dummypython/module.desc new file mode 100644 index 0000000..52c1d09 --- /dev/null +++ b/calamares/src/modules/dummypython/module.desc @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# Module metadata file for dummy python jobmodule +# Syntax is YAML 1.2 +--- +type: "job" +name: "dummypython" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/dummypython/tests/1.global b/calamares/src/modules/dummypython/tests/1.global new file mode 100644 index 0000000..d0d194e --- /dev/null +++ b/calamares/src/modules/dummypython/tests/1.global @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +firmwareType: bios +bootLoader: grub +empty: diff --git a/calamares/src/modules/finished/CMakeLists.txt b/calamares/src/modules/finished/CMakeLists.txt new file mode 100644 index 0000000..d7a4ee3 --- /dev/null +++ b/calamares/src/modules/finished/CMakeLists.txt @@ -0,0 +1,22 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +find_package(${qtname} ${QT_VERSION} CONFIG REQUIRED DBus Network) + +include_directories(${PROJECT_BINARY_DIR}/src/libcalamaresui) + +calamares_add_plugin(finished + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + FinishedViewStep.cpp + FinishedPage.cpp + UI + FinishedPage.ui + LINK_PRIVATE_LIBRARIES + ${qtname}::DBus + SHARED_LIB +) diff --git a/calamares/src/modules/finished/Config.cpp b/calamares/src/modules/finished/Config.cpp new file mode 100644 index 0000000..f54b200 --- /dev/null +++ b/calamares/src/modules/finished/Config.cpp @@ -0,0 +1,230 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "Branding.h" +#include "Settings.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include +#include +#include +#include + +const NamedEnumTable< Config::RestartMode >& +restartModes() +{ + using M = Config::RestartMode; + static const NamedEnumTable< M > table { { "never", M::Never }, + { "user-unchecked", M::UserDefaultUnchecked }, + { "unchecked", M::UserDefaultUnchecked }, + { "user-checked", M::UserDefaultChecked }, + { "checked", M::UserDefaultChecked }, + { "always", M::Always } + + }; + return table; +} + +Config::Config( QObject* parent ) + : QObject( parent ) +{ +} + +void +Config::setRestartNowMode( Config::RestartMode m ) +{ + // Can only go "down" in state (Always > UserDefaultChecked > .. > Never) + if ( m > m_restartNowMode ) + { + return; + } + + // If changing to an unconditional mode, also set other flag + if ( m == RestartMode::Always || m == RestartMode::Never ) + { + setRestartNowWanted( m == RestartMode::Always ); + } + + if ( m != m_restartNowMode ) + { + m_restartNowMode = m; + emit restartModeChanged( m ); + } +} + +void +Config::setRestartNowWanted( bool w ) +{ + // Follow the mode which may affect @p w + if ( m_restartNowMode == RestartMode::Always ) + { + w = true; + } + if ( m_restartNowMode == RestartMode::Never ) + { + w = false; + } + + if ( w != m_userWantsRestart ) + { + m_userWantsRestart = w; + emit restartNowWantedChanged( w ); + } +} + +void +Config::onInstallationFailed( const QString& message, const QString& details ) +{ + const bool msgChange = message != m_failureMessage; + const bool detChange = details != m_failureDetails; + m_failureMessage = message; + m_failureDetails = details; + if ( msgChange ) + { + emit failureMessageChanged( message ); + } + if ( detChange ) + { + emit failureDetailsChanged( message ); + } + if ( ( msgChange || detChange ) ) + { + emit failureChanged( hasFailed() ); + if ( hasFailed() ) + { + setRestartNowMode( Config::RestartMode::Never ); + } + } +} + +void +Config::doRestart( bool restartAnyway ) +{ + cDebug() << "mode=" << restartModes().find( restartNowMode() ) << " user wants restart?" << restartNowWanted() + << "force restart?" << restartAnyway; + if ( restartNowMode() != RestartMode::Never && restartAnyway ) + { + cDebug() << Logger::SubEntry << "Running restart command" << m_restartNowCommand; + QProcess::execute( "/bin/sh", { "-c", m_restartNowCommand } ); + } +} + +void +Config::doNotify( bool hasFailed, bool sendAnyway ) +{ + const char* const failName = hasFailed ? "failed" : "succeeded"; + + if ( !sendAnyway ) + { + cDebug() << "Notification not sent; completion:" << failName; + return; + } + + QDBusInterface notify( + "org.freedesktop.Notifications", "/org/freedesktop/Notifications", "org.freedesktop.Notifications" ); + if ( notify.isValid() ) + { + cDebug() << "Sending notification of completion:" << failName; + + QString title; + QString message; + if ( hasFailed ) + { + title = Calamares::Settings::instance()->isSetupMode() ? tr( "Setup Failed", "@title" ) + : tr( "Installation Failed", "@title" ); + message = Calamares::Settings::instance()->isSetupMode() + ? tr( "The setup of %1 did not complete successfully.", "@info" ) + : tr( "The installation of %1 did not complete successfully.", "@info" ); + } + else + { + title = Calamares::Settings::instance()->isSetupMode() ? tr( "Setup Complete", "@title" ) + : tr( "Installation Complete", "@title" ); + message = Calamares::Settings::instance()->isSetupMode() + ? tr( "The setup of %1 is complete.", "@info" ) + : tr( "The installation of %1 is complete.", "@info" ); + } + + const auto* branding = Calamares::Branding::instance(); + QDBusReply< uint > r = notify.call( "Notify", + QString( "Calamares" ), + QVariant( 0U ), + QString( "calamares" ), + title, + message.arg( branding->versionedName() ), + QStringList(), + QVariantMap(), + QVariant( 0 ) ); + if ( !r.isValid() ) + { + cWarning() << "Could not call org.freedesktop.Notifications.Notify at end of installation." << r.error(); + } + } + else + { + cWarning() << "Could not get dbus interface for notifications at end of installation." << notify.lastError(); + } +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + RestartMode mode = RestartMode::Never; + + //TODO:3.3 remove deprecated restart settings + QString restartMode = Calamares::getString( configurationMap, "restartNowMode" ); + if ( restartMode.isEmpty() ) + { + if ( configurationMap.contains( "restartNowEnabled" ) ) + { + cWarning() << "Configuring the finished module with deprecated restartNowEnabled settings"; + } + + bool restartNowEnabled = Calamares::getBool( configurationMap, "restartNowEnabled", false ); + bool restartNowChecked = Calamares::getBool( configurationMap, "restartNowChecked", false ); + + if ( !restartNowEnabled ) + { + mode = RestartMode::Never; + } + else + { + mode = restartNowChecked ? RestartMode::UserDefaultChecked : RestartMode::UserDefaultUnchecked; + } + } + else + { + bool ok = false; + mode = restartModes().find( restartMode, ok ); + if ( !ok ) + { + cWarning() << "Configuring the finished module with bad restartNowMode" << restartMode; + } + } + + m_restartNowMode = mode; + m_userWantsRestart = ( mode == RestartMode::Always || mode == RestartMode::UserDefaultChecked ); + emit restartModeChanged( m_restartNowMode ); + emit restartNowWantedChanged( m_userWantsRestart ); + + if ( mode != RestartMode::Never ) + { + QString restartNowCommand = Calamares::getString( configurationMap, "restartNowCommand" ); + if ( restartNowCommand.isEmpty() ) + { + restartNowCommand = QStringLiteral( "shutdown -r now" ); + } + m_restartNowCommand = restartNowCommand; + } + + m_notifyOnFinished = Calamares::getBool( configurationMap, "notifyOnFinished", false ); +} diff --git a/calamares/src/modules/finished/Config.h b/calamares/src/modules/finished/Config.h new file mode 100644 index 0000000..1349b74 --- /dev/null +++ b/calamares/src/modules/finished/Config.h @@ -0,0 +1,130 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef FINISHED_CONFIG_H +#define FINISHED_CONFIG_H + +#include "utils/NamedEnum.h" + +#include + +class Config : public QObject +{ + Q_OBJECT + + Q_PROPERTY( RestartMode restartNowMode READ restartNowMode WRITE setRestartNowMode NOTIFY restartModeChanged ) + Q_PROPERTY( bool restartNowWanted READ restartNowWanted WRITE setRestartNowWanted NOTIFY restartNowWantedChanged ) + + Q_PROPERTY( QString restartNowCommand READ restartNowCommand CONSTANT FINAL ) + Q_PROPERTY( bool notifyOnFinished READ notifyOnFinished CONSTANT FINAL ) + + Q_PROPERTY( QString failureMessage READ failureMessage NOTIFY failureMessageChanged ) + Q_PROPERTY( QString failureDetails READ failureDetails NOTIFY failureDetailsChanged ) + Q_PROPERTY( bool failed READ hasFailed NOTIFY failureChanged ) + +public: + Config( QObject* parent = nullptr ); + + enum class RestartMode + { + Never, + UserDefaultUnchecked, + UserDefaultChecked, + Always + }; + Q_ENUM( RestartMode ) + + void setConfigurationMap( const QVariantMap& configurationMap ); + +public Q_SLOTS: + RestartMode restartNowMode() const { return m_restartNowMode; } + void setRestartNowMode( RestartMode m ); + + bool restartNowWanted() const + { + if ( restartNowMode() == RestartMode::Never ) + { + return false; + } + return ( restartNowMode() == RestartMode::Always ) || m_userWantsRestart; + } + void setRestartNowWanted( bool w ); + + QString restartNowCommand() const { return m_restartNowCommand; } + bool notifyOnFinished() const { return m_notifyOnFinished; } + + QString failureMessage() const { return m_failureMessage; } + QString failureDetails() const { return m_failureDetails; } + /// Failure is if any of the failure messages is non-empty + bool hasFailed() const { return !m_failureMessage.isEmpty() || !m_failureDetails.isEmpty(); } + + /** @brief Run the restart command, if desired. + * + * This should generally not be called somewhere during the + * application's execution, but only in response to QApplication::quit() + * or something like that when the user expects the system to restart. + * + * The "if desired" part is: only if the restart mode allows it, + * **and** the user has checked the box (or done whatever to + * turn on restartNowWanted()). + * + * - The one-argument form ignores what the user wants and restarts + * if @p restartAnyway is @c true **unless** the mode is Never + * - The no-argument form uses the user setting + */ + void doRestart( bool restartAnyway ); + void doRestart() { doRestart( restartNowWanted() ); } + + /** @brief Send DBus notification + * + * At the end of installation (when the FinishedViewStep is activated), + * send a desktop notification via DBus that the install is done. + * + * - The two-argument form sends success or failure, and can be + * forced to send by setting @p sendAnyway to @c true. + * - The one-argument form sends success or failure and takes + * the notifyOnFinished() configuration into account. + * - The no-argument form checks if a failure was signalled previously + * and uses that to decide if it was a failure. + * + */ + void doNotify( bool hasFailed, bool sendAnyway ); + void doNotify( bool hasFailed ) { doNotify( hasFailed, notifyOnFinished() ); } + void doNotify() { doNotify( hasFailed() ); } + + /** @brief Tell the config the install failed + * + * This should be connected to the JobQueue and is called by + * the queue when the installation fails, with a suitable message. + */ + void onInstallationFailed( const QString& message, const QString& details ); + +signals: + void restartModeChanged( RestartMode m ); + void restartNowWantedChanged( bool w ); + void failureMessageChanged( const QString& ); + void failureDetailsChanged( const QString& ); + void failureChanged( bool ); + +private: + // Configuration parts + QString m_restartNowCommand; + RestartMode m_restartNowMode = RestartMode::Never; + bool m_userWantsRestart = false; + bool m_notifyOnFinished = false; + + // Dynamic parts + bool m_hasFailed = false; + QString m_failureMessage; + QString m_failureDetails; +}; + +const NamedEnumTable< Config::RestartMode >& restartModes(); + +#endif diff --git a/calamares/src/modules/finished/FinishedPage.cpp b/calamares/src/modules/finished/FinishedPage.cpp new file mode 100644 index 0000000..a31595f --- /dev/null +++ b/calamares/src/modules/finished/FinishedPage.cpp @@ -0,0 +1,128 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "FinishedPage.h" + +#include "Config.h" +#include "ui_FinishedPage.h" + +#include "Branding.h" +#include "Settings.h" +#include "compat/CheckBox.h" +#include "utils/Retranslator.h" + +#include + + +FinishedPage::FinishedPage( Config* config, QWidget* parent ) + : QWidget( parent ) + , ui( new Ui::FinishedPage ) +{ + ui->setupUi( this ); + + ui->mainText->setAlignment( Qt::AlignCenter ); + ui->mainText->setWordWrap( true ); + ui->mainText->setOpenExternalLinks( true ); + + connect( config, + &Config::restartModeChanged, + [ this ]( Config::RestartMode mode ) + { + using Mode = Config::RestartMode; + + ui->restartCheckBox->setVisible( mode != Mode::Never ); + ui->restartCheckBox->setEnabled( mode != Mode::Always ); + } ); + connect( config, &Config::restartNowWantedChanged, ui->restartCheckBox, &QCheckBox::setChecked ); + connect( ui->restartCheckBox, + Calamares::checkBoxStateChangedSignal, + [ config ]( Calamares::checkBoxStateType state ) + { config->setRestartNowWanted( state != Calamares::checkBoxUncheckedValue ); } ); + + CALAMARES_RETRANSLATE_SLOT( &FinishedPage::retranslate ); +} + +void +FinishedPage::focusInEvent( QFocusEvent* e ) +{ + e->accept(); +} + +void +FinishedPage::onInstallationFailed( const QString& message, const QString& details ) +{ + m_failure = !message.isEmpty() ? message : details; + retranslate(); +} + +void +FinishedPage::retranslate() +{ + + const auto* branding = Calamares::Branding::instance(); + + ui->retranslateUi( this ); + if ( !m_failure.has_value() ) + { + if ( Calamares::Settings::instance()->isSetupMode() ) + { + ui->mainText->setText( tr( "

All done.


" + "%1 has been set up on your computer.
" + "You may now start using your new system.", + "@info" ) + .arg( branding->versionedName() ) ); + ui->restartCheckBox->setToolTip( tr( "" + "

When this box is checked, your system will " + "restart immediately when you click on " + "Done " + "or close the setup program.

", + "@tooltip" ) ); + } + else + { + ui->mainText->setText( tr( "

All done.


" + "%1 has been installed on your computer.
" + "You may now restart into your new system, or continue " + "using the %2 Live environment.", + "@info" ) + .arg( branding->versionedName(), branding->productName() ) ); + ui->restartCheckBox->setToolTip( tr( "" + "

When this box is checked, your system will " + "restart immediately when you click on " + "Done " + "or close the installer.

", + "@tooltip" ) ); + } + } + else + { + const QString message = m_failure.value(); + + if ( Calamares::Settings::instance()->isSetupMode() ) + { + ui->mainText->setText( tr( "

Setup Failed


" + "%1 has not been set up on your computer.
" + "The error message was: %2.", + "@info, %1 is product name with version" ) + .arg( branding->versionedName() ) + .arg( message ) ); + } + else + { + ui->mainText->setText( tr( "

Installation Failed


" + "%1 has not been installed on your computer.
" + "The error message was: %2.", + "@info, %1 is product name with version" ) + .arg( branding->versionedName() ) + .arg( message ) ); + } + } +} diff --git a/calamares/src/modules/finished/FinishedPage.h b/calamares/src/modules/finished/FinishedPage.h new file mode 100644 index 0000000..7661afa --- /dev/null +++ b/calamares/src/modules/finished/FinishedPage.h @@ -0,0 +1,44 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef FINISHEDPAGE_H +#define FINISHEDPAGE_H + + +#include + +#include + +class Config; +namespace Ui +{ +class FinishedPage; +} // namespace Ui + +class FinishedPage : public QWidget +{ + Q_OBJECT +public: + explicit FinishedPage( Config* config, QWidget* parent = nullptr ); + + +public slots: + void onInstallationFailed( const QString& message, const QString& details ); + void retranslate(); + +protected: + void focusInEvent( QFocusEvent* e ) override; //choose the child widget to focus + +private: + Ui::FinishedPage* ui; + std::optional< QString > m_failure; +}; + +#endif // FINISHEDPAGE_H diff --git a/calamares/src/modules/finished/FinishedPage.ui b/calamares/src/modules/finished/FinishedPage.ui new file mode 100644 index 0000000..f4d36bf --- /dev/null +++ b/calamares/src/modules/finished/FinishedPage.ui @@ -0,0 +1,146 @@ + + + +SPDX-FileCopyrightText: 2015 Teo Mrnjavac <teo@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + FinishedPage + + + + 0 + 0 + 593 + 400 + + + + Form + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 50 + + + + + + + + + 3 + 0 + + + + <Calamares finished text> + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 49 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + <Restart checkbox tooltip> + + + &Restart now + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 20 + + + + + + + + + diff --git a/calamares/src/modules/finished/FinishedViewStep.cpp b/calamares/src/modules/finished/FinishedViewStep.cpp new file mode 100644 index 0000000..6fe1687 --- /dev/null +++ b/calamares/src/modules/finished/FinishedViewStep.cpp @@ -0,0 +1,105 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "FinishedViewStep.h" + +#include "Config.h" +#include "FinishedPage.h" + +#include "JobQueue.h" + +#include + +FinishedViewStep::FinishedViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_config( new Config( this ) ) + , m_widget( new FinishedPage( m_config ) ) +{ + auto jq = Calamares::JobQueue::instance(); + connect( jq, &Calamares::JobQueue::failed, m_config, &Config::onInstallationFailed ); + connect( jq, &Calamares::JobQueue::failed, m_widget, &FinishedPage::onInstallationFailed ); + + emit nextStatusChanged( true ); +} + + +FinishedViewStep::~FinishedViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +FinishedViewStep::prettyName() const +{ + return tr( "Finish", "@label" ); +} + + +QWidget* +FinishedViewStep::widget() +{ + return m_widget; +} + + +bool +FinishedViewStep::isNextEnabled() const +{ + return false; +} + + +bool +FinishedViewStep::isBackEnabled() const +{ + return false; +} + + +bool +FinishedViewStep::isAtBeginning() const +{ + return true; +} + + +bool +FinishedViewStep::isAtEnd() const +{ + return true; +} + + +void +FinishedViewStep::onActivate() +{ + m_config->doNotify(); + connect( qApp, &QApplication::aboutToQuit, m_config, qOverload<>( &Config::doRestart ) ); +} + + +Calamares::JobList +FinishedViewStep::jobs() const +{ + return Calamares::JobList(); +} + +void +FinishedViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( FinishedViewStepFactory, registerPlugin< FinishedViewStep >(); ) diff --git a/calamares/src/modules/finished/FinishedViewStep.h b/calamares/src/modules/finished/FinishedViewStep.h new file mode 100644 index 0000000..c0f6415 --- /dev/null +++ b/calamares/src/modules/finished/FinishedViewStep.h @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef FINISHEDVIEWSTEP_H +#define FINISHEDVIEWSTEP_H + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +class Config; +class FinishedPage; + +class PLUGINDLLEXPORT FinishedViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit FinishedViewStep( QObject* parent = nullptr ); + ~FinishedViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onActivate() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + Config* m_config; + FinishedPage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( FinishedViewStepFactory ) + +#endif diff --git a/calamares/src/modules/finished/finished.conf b/calamares/src/modules/finished/finished.conf new file mode 100644 index 0000000..7abfb36 --- /dev/null +++ b/calamares/src/modules/finished/finished.conf @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the "finished" page, which is usually shown only at +# the end of the installation (successful or not). +--- +# DEPRECATED +# +# The finished page can hold a "restart system now" checkbox. +# If this is false, no checkbox is shown and the system is not restarted +# when Calamares exits. +# restartNowEnabled: true + +# DEPRECATED +# +# Initial state of the checkbox "restart now". Only relevant when the +# checkbox is shown by restartNowEnabled. +# restartNowChecked: false + +# Behavior of the "restart system now" button. +# +# There are four usable values: +# - never +# Does not show the button and does not restart. +# This matches the old behavior with restartNowEnabled=false. +# - user-unchecked +# Shows the button, defaults to unchecked, restarts if it is checked. +# This matches the old behavior with restartNowEnabled=true and restartNowChecked=false. +# - user-checked +# Shows the button, defaults to checked, restarts if it is checked. +# This matches the old behavior with restartNowEnabled=true and restartNowChecked=true. +# - always +# Shows the button, checked, but the user cannot change it. +# This is new behavior. +# +# The three combinations of legacy values are still supported. +restartNowMode: user-unchecked + +# If the checkbox is shown, and the checkbox is checked, then when +# Calamares exits from the finished-page it will run this command. +# If not set, falls back to "shutdown -r now". +restartNowCommand: "systemctl -i reboot" + +# When the last page is (successfully) reached, send a DBus notification +# to the desktop that the installation is done. This works only if the +# user as whom Calamares is run, can reach the regular desktop session bus. +notifyOnFinished: false diff --git a/calamares/src/modules/finished/finished.schema.yaml b/calamares/src/modules/finished/finished.schema.yaml new file mode 100644 index 0000000..380292d --- /dev/null +++ b/calamares/src/modules/finished/finished.schema.yaml @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/finished +additionalProperties: false +type: object +properties: + restartNowEnabled: { type: boolean, default: true } # TODO:3.3: remove + restartNowChecked: { type: boolean, default: false } # TODO:3.3: remove + restartNowCommand: { type: string } + restartNowMode: { type: string, enum: [ never, user-unchecked, user-checked, always ] } + notifyOnFinished: { type: boolean } diff --git a/calamares/src/modules/finishedq/CMakeLists.txt b/calamares/src/modules/finishedq/CMakeLists.txt new file mode 100644 index 0000000..278feb5 --- /dev/null +++ b/calamares/src/modules/finishedq/CMakeLists.txt @@ -0,0 +1,31 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2021 Anke Boersma +# SPDX-License-Identifier: BSD-2-Clause +# +if(NOT WITH_QML) + calamares_skip_module( "finishedq (QML is not supported in this build)" ) + return() +endif() + +find_package(${qtname} ${QT_VERSION} CONFIG COMPONENTS DBus Network) +if(NOT TARGET ${qtname}::DBus OR NOT TARGET ${qtname}::Network) + calamares_skip_module( "finishedq (missing DBus or Network)" ) + return() +endif() + +set(_finished ${CMAKE_CURRENT_SOURCE_DIR}/../finished) +include_directories(${_finished}) + +calamares_add_plugin(finishedq + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + FinishedQmlViewStep.cpp + ${_finished}/Config.cpp + RESOURCES + finishedq${QT_VERSION_SUFFIX}.qrc + LINK_PRIVATE_LIBRARIES + ${qtname}::DBus + SHARED_LIB +) diff --git a/calamares/src/modules/finishedq/FinishedQmlViewStep.cpp b/calamares/src/modules/finishedq/FinishedQmlViewStep.cpp new file mode 100644 index 0000000..bf605ac --- /dev/null +++ b/calamares/src/modules/finishedq/FinishedQmlViewStep.cpp @@ -0,0 +1,92 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "FinishedQmlViewStep.h" + +#include "Config.h" + +#include "JobQueue.h" +#include + +CALAMARES_PLUGIN_FACTORY_DEFINITION( FinishedQmlViewStepFactory, registerPlugin< FinishedQmlViewStep >(); ) + +FinishedQmlViewStep::FinishedQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) + , m_config( new Config( this ) ) +{ + auto jq = Calamares::JobQueue::instance(); + connect( jq, &Calamares::JobQueue::failed, m_config, &Config::onInstallationFailed ); + + emit nextStatusChanged( true ); +} + +QString +FinishedQmlViewStep::prettyName() const +{ + return tr( "Finish", "@label" ); +} + +bool +FinishedQmlViewStep::isNextEnabled() const +{ + return false; +} + + +bool +FinishedQmlViewStep::isBackEnabled() const +{ + return false; +} + + +bool +FinishedQmlViewStep::isAtBeginning() const +{ + return true; +} + + +bool +FinishedQmlViewStep::isAtEnd() const +{ + return true; +} + + +void +FinishedQmlViewStep::onActivate() +{ + m_config->doNotify(); + connect( qApp, &QApplication::aboutToQuit, m_config, qOverload<>( &Config::doRestart ) ); + QmlViewStep::onActivate(); +} + + +Calamares::JobList +FinishedQmlViewStep::jobs() const +{ + return Calamares::JobList(); +} + +QObject* +FinishedQmlViewStep::getConfig() +{ + return m_config; +} + +void +FinishedQmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); + Calamares::QmlViewStep::setConfigurationMap( configurationMap ); +} diff --git a/calamares/src/modules/finishedq/FinishedQmlViewStep.h b/calamares/src/modules/finishedq/FinishedQmlViewStep.h new file mode 100644 index 0000000..7bcf0e6 --- /dev/null +++ b/calamares/src/modules/finishedq/FinishedQmlViewStep.h @@ -0,0 +1,57 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef FINISHEDQMLVIEWSTEP_H +#define FINISHEDQMLVIEWSTEP_H + +#include + +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/QmlViewStep.h" + +#include + +class Config; + +class PLUGINDLLEXPORT FinishedQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + explicit FinishedQmlViewStep( QObject* parent = nullptr ); + + QString prettyName() const override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onActivate() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + QObject* getConfig() override; + +private: + Config* m_config; + + bool m_installFailed; // Track if onInstallationFailed() was called +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( FinishedQmlViewStepFactory ) + +#endif diff --git a/calamares/src/modules/finishedq/finishedq-qt6.qml b/calamares/src/modules/finishedq/finishedq-qt6.qml new file mode 100644 index 0000000..7e95cc2 --- /dev/null +++ b/calamares/src/modules/finishedq/finishedq-qt6.qml @@ -0,0 +1,99 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 - 2023 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * License-Filename: LICENSE + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import QtQuick.Window + +Page { + + id: finished + + width: parent.width + height: parent.height + + header: Kirigami.Heading { + width: parent.width + height: 100 + id: header + Layout.fillWidth: true + horizontalAlignment: Qt.AlignHCenter + color: Kirigami.Theme.textColor + level: 1 + text: qsTr("Installation Completed", "@title") + + Text { + anchors.top: header.bottom + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + font.pointSize: 12 + text: qsTr("%1 has been installed on your computer.
+ You may now restart into your new system, or continue using the Live environment.", "@info, %1 is the product name") + .arg(Branding.string(Branding.ProductName)) + } + + Image { + source: "seedling.svg" + anchors.top: header.bottom + anchors.topMargin: 80 + anchors.horizontalCenter: parent.horizontalCenter + width: 64 + height: 64 + mipmap: true + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight|Qt.AlignVCenter + anchors.centerIn: parent + spacing: 6 + + Button { + id: button + text: qsTr("Close Installer", "@button") + icon.name: "application-exit" + onClicked: { ViewManager.quit(); } + } + + Button { + text: qsTr("Restart System", "@button") + icon.name: "system-reboot" + onClicked: { config.doRestart(true); } + } + } + + Item { + + Layout.fillHeight: true + Layout.fillWidth: true + anchors.bottom: parent.bottom + anchors.bottomMargin : 100 + anchors.horizontalCenter: parent.horizontalCenter + + Text { + anchors.centerIn: parent + anchors.top: parent.top + horizontalAlignment: Text.AlignHCenter + text: qsTr("

A full log of the install is available as installation.log in the home directory of the Live user.
+ This log is copied to /var/log/installation.log of the target system.

", "@info") + } + } + + function onActivate() { + } + + function onLeave() { + } +} diff --git a/calamares/src/modules/finishedq/finishedq-qt6.qrc b/calamares/src/modules/finishedq/finishedq-qt6.qrc new file mode 100644 index 0000000..c0daf36 --- /dev/null +++ b/calamares/src/modules/finishedq/finishedq-qt6.qrc @@ -0,0 +1,6 @@ + + + finishedq-qt6.qml + seedling.svg + + diff --git a/calamares/src/modules/finishedq/finishedq.conf b/calamares/src/modules/finishedq/finishedq.conf new file mode 100644 index 0000000..ee226c3 --- /dev/null +++ b/calamares/src/modules/finishedq/finishedq.conf @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the "finishedq" page, which is usually shown only at +# the end of the installation (successful or not). +# +# See the documentation for the "finished" module for a full explanation +# of the configuration options; the description here applies primarily +# to the use that the QML makes of them. +--- +# Behavior of the "restart system now" button. +# +# The example QML for this module offers a "Restart Now" button, +# which the user can click on. It calls directly to the restart +# function. If the user closes the installer in some other way, +# (the "Done" button or close-window) a restart **might** happen: +# +# - never +# Do not restart (this will also block the "Restart Now" button, +# so it is not very useful) +# - user-unchecked +# Do not restart on other ways of closing the window. No checkbox +# is shown in the example QML, so there is no way for the user to +# express a choice -- except by clicking the "Restart Now" button. +# - user-checked +# Do restart on other ways of closing the window. This makes close +# and "Restart Now" do the same thing. No checkbox is shown by the QML, +# so the machine will **always** restart. +# - always +# Same as above. +# +# For the **specific** example QML included with this module, only +# *user-unchecked* really makes sense. +restartNowMode: user-unchecked +restartNowCommand: "systemctl -i reboot" +notifyOnFinished: false diff --git a/calamares/src/modules/finishedq/finishedq.qml b/calamares/src/modules/finishedq/finishedq.qml new file mode 100644 index 0000000..e0fdd97 --- /dev/null +++ b/calamares/src/modules/finishedq/finishedq.qml @@ -0,0 +1,101 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * License-Filename: LICENSE + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.7 as Kirigami +import QtGraphicalEffects 1.0 +import QtQuick.Window 2.3 + +Page { + + id: finished + + width: parent.width + height: parent.height + + header: Kirigami.Heading { + width: parent.width + height: 100 + id: header + Layout.fillWidth: true + horizontalAlignment: Qt.AlignHCenter + color: Kirigami.Theme.textColor + level: 1 + text: qsTr("Installation Completed") + + Text { + anchors.top: header.bottom + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + font.pointSize: 12 + text: qsTr("%1 has been installed on your computer.
+ You may now restart into your new system, or continue using the Live environment.").arg(Branding.string(Branding.ProductName)) + } + + Image { + source: "seedling.svg" + anchors.top: header.bottom + anchors.topMargin: 80 + anchors.horizontalCenter: parent.horizontalCenter + width: 64 + height: 64 + mipmap: true + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight|Qt.AlignVCenter + anchors.centerIn: parent + spacing: 6 + + Button { + id: button + text: qsTr("Close Installer") + icon.name: "application-exit" + onClicked: { ViewManager.quit(); } + } + + Button { + text: qsTr("Restart System") + icon.name: "system-reboot" + onClicked: { config.doRestart(true); } + } + } + + Item { + + Layout.fillHeight: true + Layout.fillWidth: true + anchors.bottom: parent.bottom + anchors.bottomMargin : 100 + anchors.horizontalCenter: parent.horizontalCenter + + Text { + anchors.centerIn: parent + anchors.top: parent.top + horizontalAlignment: Text.AlignHCenter + text: qsTr("

A full log of the install is available as installation.log in the home directory of the Live user.
+ This log is copied to /var/log/installation.log of the target system.

") + } + } + + function onActivate() + { + } + + function onLeave() + { + } +} diff --git a/calamares/src/modules/finishedq/finishedq.qrc b/calamares/src/modules/finishedq/finishedq.qrc new file mode 100644 index 0000000..e0918eb --- /dev/null +++ b/calamares/src/modules/finishedq/finishedq.qrc @@ -0,0 +1,6 @@ + + + finishedq.qml + seedling.svg + + diff --git a/calamares/src/modules/finishedq/finishedq@mobile.qml b/calamares/src/modules/finishedq/finishedq@mobile.qml new file mode 100644 index 0000000..61ea785 --- /dev/null +++ b/calamares/src/modules/finishedq/finishedq@mobile.qml @@ -0,0 +1,122 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * License-Filename: LICENSE + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.7 as Kirigami +import QtGraphicalEffects 1.0 +import QtQuick.Window 2.3 + +Page { + + id: finished + + width: parent.width + height: parent.height + + header: Kirigami.Heading { + width: parent.width + height: 100 + id: header + Layout.fillWidth: true + horizontalAlignment: Qt.AlignHCenter + color: Kirigami.Theme.textColor + level: 1 + text: qsTr("Installation Completed", "@title") + + Text { + anchors.top: header.bottom + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + font.pointSize: 12 + text: qsTr("%1 has been installed on your computer.
+ You may now restart your device.", "@info, %1 is the product name") + .arg(Branding.string(Branding.ProductName)) + } + + Image { + source: "seedling.svg" + anchors.top: header.bottom + anchors.topMargin: 80 + anchors.horizontalCenter: parent.horizontalCenter + width: 64 + height: 64 + mipmap: true + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight|Qt.AlignVCenter + anchors.centerIn: parent + spacing: 6 + + Button { + id: button + text: qsTr("Close", "@button") + icon.name: "application-exit" + onClicked: { ViewManager.quit(); } + } + + Button { + text: qsTr("Restart", "@button") + icon.name: "system-reboot" + onClicked: { config.doRestart(true); } + } + } + + Item { + + Layout.fillHeight: true + Layout.fillWidth: true + anchors.bottom: parent.bottom + anchors.bottomMargin : 100 + anchors.horizontalCenter: parent.horizontalCenter + + ProgressBar { + id: autoRestartBar + value: 1.0 + anchors.horizontalCenter: parent.horizontalCenter + + } + + Timer { + id: autoRestartTimer + // This is in milliseconds and should be less than 1000 (because of logic in onTriggered) + interval: 100 + repeat: true + running: false + // Whenever the timer fires (1000 / interval times a second) count the progress bar down + // by 1%. When the bar is empty, try to restart normally; as a backup, when the bar + // is empty change settings and schedule it to quit 1000 milliseconds (1s) later. + onTriggered: { + autoRestartBar.value -= 0.01; + if (autoRestartBar.value <= 0.0) { + // First time through here, set the interval to 1000 so that the + // second time (1 second later) goes to quit(). + if ( interval > 999) { ViewManager.quit(); } + else { config.doRestart(true); running = false; interval = 1000; repeat = false; start(); } + } + } + } + } + + function onActivate() + { + autoRestartTimer.running = true + } + + function onLeave() + { + } +} diff --git a/calamares/src/modules/finishedq/seedling.svg b/calamares/src/modules/finishedq/seedling.svg new file mode 100644 index 0000000..8f3501b --- /dev/null +++ b/calamares/src/modules/finishedq/seedling.svg @@ -0,0 +1 @@ + diff --git a/calamares/src/modules/finishedq/seedling.svg.license b/calamares/src/modules/finishedq/seedling.svg.license new file mode 100644 index 0000000..0604e8d --- /dev/null +++ b/calamares/src/modules/finishedq/seedling.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021 FontAwesome +SPDX-License-Identifier: CC-BY-4.0 diff --git a/calamares/src/modules/fsresizer/CMakeLists.txt b/calamares/src/modules/fsresizer/CMakeLists.txt new file mode 100644 index 0000000..43ba6d6 --- /dev/null +++ b/calamares/src/modules/fsresizer/CMakeLists.txt @@ -0,0 +1,38 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +include(KPMcoreHelper) + +if(KPMcore_FOUND) + include_directories(${KPMCORE_INCLUDE_DIR} ${CMAKE_SOURCE_DIR}/src/modules/partition) + + # The PartitionIterator is a small class, and it's easiest -- but also a + # gross hack -- to just compile it again from the partition module tree. + calamares_add_plugin(fsresizer + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + ResizeFSJob.cpp + LINK_PRIVATE_LIBRARIES + calamares::kpmcore + COMPILE_DEFINITIONS ${KPMcore_API_DEFINITIONS} + SHARED_LIB + ) + + calamares_add_test( + fsresizertest + SOURCES Tests.cpp + LIBRARIES + calamares_job_fsresizer # From above + yamlcpp::yamlcpp + DEFINITIONS ${KPMcore_API_DEFINITIONS} + ) +else() + if(NOT KPMcore_FOUND) + calamares_skip_module( "fsresizer (missing suitable KPMcore)" ) + else() + calamares_skip_module( "fsresizer (missing dependencies for KPMcore)" ) + endif() +endif() diff --git a/calamares/src/modules/fsresizer/ResizeFSJob.cpp b/calamares/src/modules/fsresizer/ResizeFSJob.cpp new file mode 100644 index 0000000..7704e32 --- /dev/null +++ b/calamares/src/modules/fsresizer/ResizeFSJob.cpp @@ -0,0 +1,259 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ResizeFSJob.h" + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "partition/PartitionIterator.h" +#include "utils/Logger.h" +#include "utils/Units.h" +#include "utils/Variant.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +using Calamares::Partition::PartitionIterator; + +ResizeFSJob::ResizeFSJob( QObject* parent ) + : Calamares::CppJob( parent ) + , m_required( false ) +{ +} + +ResizeFSJob::~ResizeFSJob() {} + +QString +ResizeFSJob::prettyName() const +{ + return tr( "Performing file system resize…", "@status" ); +} + +ResizeFSJob::PartitionMatch +ResizeFSJob::findPartition() +{ + using DeviceList = QList< Device* >; + DeviceList devices + = m_kpmcore.backend()->scanDevices( /* not includeReadOnly, not includeLoopback */ ScanFlag( 0 ) ); + + cDebug() << "ResizeFSJob found" << devices.count() << "devices."; + for ( DeviceList::iterator dev_it = devices.begin(); dev_it != devices.end(); ++dev_it ) + { + if ( !( *dev_it ) ) + { + continue; + } + cDebug() << "ResizeFSJob found" << ( *dev_it )->deviceNode(); + for ( auto part_it = PartitionIterator::begin( *dev_it ); part_it != PartitionIterator::end( *dev_it ); + ++part_it ) + { + cDebug() << Logger::SubEntry << ( *part_it )->mountPoint() << "on" << ( *part_it )->deviceNode(); + if ( ( !m_fsname.isEmpty() && ( *part_it )->mountPoint() == m_fsname ) + || ( !m_devicename.isEmpty() && ( *part_it )->deviceNode() == m_devicename ) ) + { + cDebug() << Logger::SubEntry << "matched configuration dev=" << m_devicename << "fs=" << m_fsname; + return PartitionMatch( *dev_it, *part_it ); + } + } + } + + cDebug() << "No match for configuration dev=" << m_devicename << "fs=" << m_fsname; + return PartitionMatch( nullptr, nullptr ); +} + +/** @brief Returns the last sector the matched partition should occupy. + * + * Returns a sector number. Returns -1 if something is wrong (e.g. + * can't resize at all, or missing data). Returns 0 if the resize + * won't fit because it doesn't satisfy the settings for atleast + * and size (or won't grow at all because the partition is blocked + * by occupied space after it). + */ +qint64 +ResizeFSJob::findGrownEnd( ResizeFSJob::PartitionMatch m ) +{ + if ( !m.first || !m.second ) + { + return -1; // Missing device data + } + if ( !ResizeOperation::canGrow( m.second ) ) + { + return -1; // Operation is doomed + } + if ( !m_size.isValid() ) + { + return -1; // Must have a grow-size + } + + cDebug() << "Containing device size" << m.first->totalLogical(); + qint64 last_available = m.first->totalLogical() - 1; // Numbered from 0 + qint64 last_currently = m.second->lastSector(); + cDebug() << "Growing partition" << m.second->firstSector() << '-' << last_currently; + + for ( auto part_it = PartitionIterator::begin( m.first ); part_it != PartitionIterator::end( m.first ); ++part_it ) + { + qint64 next_start = ( *part_it )->firstSector(); + qint64 next_end = ( *part_it )->lastSector(); + if ( next_start > next_end ) + { + cWarning() << "Corrupt partition has end" << next_end << " < start" << next_start; + std::swap( next_start, next_end ); + } + if ( ( *part_it )->roles().has( PartitionRole::Unallocated ) ) + { + cDebug() << Logger::SubEntry << "ignoring unallocated" << next_start << '-' << next_end; + continue; + } + cDebug() << Logger::SubEntry << "comparing" << next_start << '-' << next_end; + if ( ( next_start > last_currently ) && ( next_start < last_available ) ) + { + cDebug() << Logger::SubEntry << "shrunk last available to" << next_start; + last_available = next_start - 1; // Before that one starts + } + } + + if ( !( last_available > last_currently ) ) + { + cDebug() << "Partition cannot grow larger."; + return 0; + } + + qint64 expand = last_available - last_currently; // number of sectors + if ( m_atleast.isValid() ) + { + qint64 required = m_atleast.toSectors( m.first->totalLogical(), m.first->logicalSize() ); + if ( expand < required ) + { + cDebug() << Logger::SubEntry << "need to expand by" << required << "but only" << expand << "is available."; + return 0; + } + } + + qint64 wanted = m_size.toSectors( expand, m.first->logicalSize() ); + if ( wanted < expand ) + { + cDebug() << Logger::SubEntry << "only growing by" << wanted << "instead of full" << expand; + last_available -= ( expand - wanted ); + } + + return last_available; +} + +Calamares::JobResult +ResizeFSJob::exec() +{ + if ( !isValid() ) + { + return Calamares::JobResult::error( + tr( "Invalid configuration", "@error" ), + tr( "The file-system resize job has an invalid configuration and will not run.", "@error" ) ); + } + + if ( !m_kpmcore ) + { + cWarning() << "Could not load KPMCore backend (2)."; + return Calamares::JobResult::error( + tr( "KPMCore not available", "@error" ), + tr( "Calamares cannot start KPMCore for the file system resize job.", "@error" ) ); + } + m_kpmcore.backend()->initFSSupport(); // Might not be enough, see below + + // Now get the partition and FS we want to work on + PartitionMatch m = findPartition(); + if ( !m.first || !m.second ) + { + return Calamares::JobResult::error( + tr( "Resize failed.", "@error" ), + !m_fsname.isEmpty() + ? tr( "The filesystem %1 could not be found in this system, and cannot be resized.", "@info" ) + .arg( m_fsname ) + : tr( "The device %1 could not be found in this system, and cannot be resized.", "@info" ) + .arg( m_devicename ) ); + } + + m.second->fileSystem().init(); // Initialize support for specific FS + if ( !ResizeOperation::canGrow( m.second ) ) + { + cDebug() << "canGrow() returned false."; + return Calamares::JobResult::error( + tr( "Resize Failed", "@error" ), + !m_fsname.isEmpty() ? tr( "The filesystem %1 cannot be resized.", "@error" ).arg( m_fsname ) + : tr( "The device %1 cannot be resized.", "@error" ).arg( m_devicename ) ); + } + + qint64 new_end = findGrownEnd( m ); + cDebug() << "Resize from" << m.second->firstSector() << '-' << m.second->lastSector() << '(' << m.second->length() + << ')' << "to -" << new_end; + + if ( new_end < 0 ) + { + return Calamares::JobResult::error( + tr( "Resize Failed", "@error" ), + !m_fsname.isEmpty() ? tr( "The filesystem %1 cannot be resized.", "@error" ).arg( m_fsname ) + : tr( "The device %1 cannot be resized.", "@error" ).arg( m_devicename ) ); + } + if ( new_end == 0 ) + { + cWarning() << "Resize operation on" << m_fsname << m_devicename << "skipped as not-useful."; + if ( m_required ) + { + return Calamares::JobResult::error( + tr( "Resize Failed", "@error" ), + !m_fsname.isEmpty() ? tr( "The file system %1 must be resized, but cannot.", "@info" ).arg( m_fsname ) + : tr( "The device %1 must be resized, but cannot", "@info" ).arg( m_fsname ) ); + } + + return Calamares::JobResult::ok(); + } + + if ( ( new_end > 0 ) && ( new_end > m.second->lastSector() ) ) + { + ResizeOperation op( *m.first, *m.second, m.second->firstSector(), new_end ); + Report op_report( nullptr ); + if ( op.execute( op_report ) ) + { + cDebug() << "Resize operation OK."; + } + else + { + cDebug() << "Resize failed." << op_report.output(); + return Calamares::JobResult::error( tr( "Resize Failed", "@error" ), op_report.toText() ); + } + } + + return Calamares::JobResult::ok(); +} + +void +ResizeFSJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_fsname = configurationMap[ "fs" ].toString(); + m_devicename = configurationMap[ "dev" ].toString(); + + if ( m_fsname.isEmpty() && m_devicename.isEmpty() ) + { + cWarning() << "No fs or dev configured for resize."; + return; + } + + m_size = PartitionSize( configurationMap[ "size" ].toString() ); + m_atleast = PartitionSize( configurationMap[ "atleast" ].toString() ); + + m_required = Calamares::getBool( configurationMap, "required", false ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( ResizeFSJobFactory, registerPlugin< ResizeFSJob >(); ) diff --git a/calamares/src/modules/fsresizer/ResizeFSJob.h b/calamares/src/modules/fsresizer/ResizeFSJob.h new file mode 100644 index 0000000..e31c0b9 --- /dev/null +++ b/calamares/src/modules/fsresizer/ResizeFSJob.h @@ -0,0 +1,71 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef RESIZEFSJOB_H +#define RESIZEFSJOB_H + +#include +#include + +#include "CppJob.h" + +#include "partition/KPMManager.h" +#include "partition/PartitionSize.h" +#include "utils/PluginFactory.h" + +#include "DllMacro.h" + +class CoreBackend; // From KPMCore +class Device; // From KPMCore +class Partition; + +using PartitionSize = Calamares::Partition::PartitionSize; + +class PLUGINDLLEXPORT ResizeFSJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit ResizeFSJob( QObject* parent = nullptr ); + ~ResizeFSJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + /** @brief Is the configuration of this job valid? */ + bool isValid() const { return ( !m_fsname.isEmpty() || !m_devicename.isEmpty() ) && m_size.isValid(); } + + QString name() const { return m_fsname.isEmpty() ? m_devicename : m_fsname; } + + PartitionSize size() const { return m_size; } + + PartitionSize minimumSize() const { return m_atleast; } + +private: + Calamares::Partition::KPMManager m_kpmcore; + PartitionSize m_size; + PartitionSize m_atleast; + QString m_fsname; // Either this, or devicename, is set, not both + QString m_devicename; + bool m_required; + + using PartitionMatch = QPair< Device*, Partition* >; + /** @brief Find the configured FS */ + PartitionMatch findPartition(); + + /** @brief Return a new end-sector for the given dev-part pair. */ + qint64 findGrownEnd( PartitionMatch ); +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( ResizeFSJobFactory ) + +#endif // RESIZEFSJOB_H diff --git a/calamares/src/modules/fsresizer/Tests.cpp b/calamares/src/modules/fsresizer/Tests.cpp new file mode 100644 index 0000000..ff12310 --- /dev/null +++ b/calamares/src/modules/fsresizer/Tests.cpp @@ -0,0 +1,125 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Tests.h" + +#include "ResizeFSJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" + +#include "utils/Logger.h" +#include "utils/Yaml.h" + +#include + +#include +#include + +using SizeUnit = Calamares::Partition::SizeUnit; + +QTEST_GUILESS_MAIN( FSResizerTests ) + +FSResizerTests::FSResizerTests() {} + +FSResizerTests::~FSResizerTests() {} + +void +FSResizerTests::initTestCase() +{ +} + +void +FSResizerTests::testConfigurationRobust() +{ + ResizeFSJob j; + + // Empty config + j.setConfigurationMap( QVariantMap() ); + QVERIFY( j.name().isEmpty() ); + QCOMPARE( j.size().unit(), SizeUnit::None ); + QCOMPARE( j.minimumSize().unit(), SizeUnit::None ); + + // Config is missing fs and dev, so it isn't valid + YAML::Node doc0 = YAML::Load( R"(--- + size: 100% + atleast: 600MiB + )" ); + j.setConfigurationMap( Calamares::YAML::mapToVariant( doc0 ) ); + QVERIFY( j.name().isEmpty() ); + QCOMPARE( j.size().unit(), SizeUnit::None ); + QCOMPARE( j.minimumSize().unit(), SizeUnit::None ); + QCOMPARE( j.size().value(), 0 ); + QCOMPARE( j.minimumSize().value(), 0 ); +} + +void +FSResizerTests::testConfigurationValues() +{ + ResizeFSJob j; + + // Check both + YAML::Node doc0 = YAML::Load( R"(--- + fs: / + size: 100% + atleast: 600MiB + )" ); + j.setConfigurationMap( Calamares::YAML::mapToVariant( doc0 ) ); + QVERIFY( !j.name().isEmpty() ); + QCOMPARE( j.name(), QString( "/" ) ); + QCOMPARE( j.size().unit(), SizeUnit::Percent ); + QCOMPARE( j.minimumSize().unit(), SizeUnit::MiB ); + QCOMPARE( j.size().value(), 100 ); + QCOMPARE( j.minimumSize().value(), 600 ); + + // Silly config has bad atleast value + doc0 = YAML::Load( R"(--- + fs: / + dev: /dev/m00 + size: 72 MiB + atleast: 127 % + )" ); + j.setConfigurationMap( Calamares::YAML::mapToVariant( doc0 ) ); + QVERIFY( !j.name().isEmpty() ); + QCOMPARE( j.name(), QString( "/" ) ); + QCOMPARE( j.size().unit(), SizeUnit::MiB ); + QCOMPARE( j.minimumSize().unit(), SizeUnit::None ); + QCOMPARE( j.size().value(), 72 ); + QCOMPARE( j.minimumSize().value(), 0 ); + + // Silly config has bad atleast value + doc0 = YAML::Load( R"(--- + dev: /dev/m00 + size: 72 MiB + atleast: 127 % + )" ); + j.setConfigurationMap( Calamares::YAML::mapToVariant( doc0 ) ); + QVERIFY( !j.name().isEmpty() ); + QCOMPARE( j.name(), QString( "/dev/m00" ) ); + QCOMPARE( j.size().unit(), SizeUnit::MiB ); + QCOMPARE( j.minimumSize().unit(), SizeUnit::None ); + QCOMPARE( j.size().value(), 72 ); + QCOMPARE( j.minimumSize().value(), 0 ); + + // Normal config + doc0 = YAML::Load( R"(--- + fs: / +# dev: /dev/m00 + size: 71MiB +# atleast: 127% + )" ); + j.setConfigurationMap( Calamares::YAML::mapToVariant( doc0 ) ); + QVERIFY( !j.name().isEmpty() ); + QCOMPARE( j.name(), QString( "/" ) ); + QCOMPARE( j.size().unit(), SizeUnit::MiB ); + QCOMPARE( j.minimumSize().unit(), SizeUnit::None ); + QCOMPARE( j.size().value(), 71 ); + QCOMPARE( j.minimumSize().value(), 0 ); +} diff --git a/calamares/src/modules/fsresizer/Tests.h b/calamares/src/modules/fsresizer/Tests.h new file mode 100644 index 0000000..f3d2308 --- /dev/null +++ b/calamares/src/modules/fsresizer/Tests.h @@ -0,0 +1,30 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TESTS_H +#define TESTS_H + +#include + +class FSResizerTests : public QObject +{ + Q_OBJECT +public: + FSResizerTests(); + ~FSResizerTests() override; + +private Q_SLOTS: + void initTestCase(); + // Can handle missing values + void testConfigurationRobust(); + // Can parse % and MiB values + void testConfigurationValues(); +}; + +#endif diff --git a/calamares/src/modules/fsresizer/fsresizer.conf b/calamares/src/modules/fsresizer/fsresizer.conf new file mode 100644 index 0000000..e58c398 --- /dev/null +++ b/calamares/src/modules/fsresizer/fsresizer.conf @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Module that resizes a single FS to fill the entire (rest) of +# a device. This is used in OEM situations where an image is +# flashed onto an SD card (or similar) and used to boot a device, +# after which the FS should expand to fill the SD card. +# +# Example: a distro produces a 6GiB large image that is +# written to an 8GiB SD card; the FS should expand to take +# advantage of the unused 2GiB. The FS should expand much +# more if the same image is written to a 16GiB card. +--- + +# Which FS needs to be grown? Choose one way to identify it: +# - *fs* names a mount point which should already be mounted +# in the system. +# - *dev* names a device +fs: / +# dev: /dev/mmcblk0p1 + +# How much of the total remaining space should the FS use? +# The only sensible amount is "all of it". The value is +# in percent, so set it to 100. Perhaps a fixed size is +# needed (that would be weird though, since you don't know +# how big the card is), use MiB as suffix in that case. +# If missing, then it's assumed to be 0, and no resizing +# will happen. +# +# Percentages apply to **available space**. +size: 100% + +# Resizing might not be worth it, though. Set the minimum +# that it must grow; if it cannot grow that much, the +# resizing is skipped. Can be in percentage or absolute +# size, as above. If missing, then it's assumed to be 0, +# which means resizing is always worthwhile. +# +# If *atleast* is not zero, then the setting *required*, +# below, becomes relevant. +# +# Percentages apply to **total device size**. +#atleast: 1000MiB + +# When *atleast* is not zero, then the resize may be +# recommended (the default) or **required**. If the +# resize is required and cannot be carried out (because +# there's not enough space), then that is a fatal +# error for the installer. By default, resize is only +# recommended and it is not an error for no resize to be +# carried out. +required: false diff --git a/calamares/src/modules/fstab/fstab.conf b/calamares/src/modules/fstab/fstab.conf new file mode 100644 index 0000000..5c5c566 --- /dev/null +++ b/calamares/src/modules/fstab/fstab.conf @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Creates /etc/fstab and /etc/crypttab in the target system. +# Also creates mount points for all the filesystems. +# +# When creating fstab entries for a filesystem, this module +# uses the options previously defined in the mount module +--- + +# Additional options added to each line in /etc/crypttab +crypttabOptions: luks +# For Debian and Debian-based distributions, change the above line to: +# crypttabOptions: luks,keyscript=/bin/cat + +# Options for handling /tmp in /etc/fstab +# Currently default (required) and ssd are supported +# The corresponding string can contain the following variables: +# tmpfs: true or tmpfs: false to either mount /tmp as tmpfs or not +# options: "" +# +# Example: +#tmpOptions: +# default: +# tmpfs: false +# options: "" +# ssd: +# tmpfs: true +# options: "defaults,noatime,mode=1777" +# +tmpOptions: + default: + tmpfs: false + options: "" + ssd: + tmpfs: true + options: "defaults,noatime,mode=1777" diff --git a/calamares/src/modules/fstab/fstab.schema.yaml b/calamares/src/modules/fstab/fstab.schema.yaml new file mode 100644 index 0000000..e298a9d --- /dev/null +++ b/calamares/src/modules/fstab/fstab.schema.yaml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/fstab +additionalProperties: false +type: object +properties: + crypttabOptions: { type: string } + tmpOptions: + type: object + additionalProperties: false + properties: + "default": + type: object + additionalProperties: false + properties: + tmpfs: { type: boolean } + options: { type: string } + ssd: + type: object + additionalProperties: false + properties: + tmpfs: { type: boolean } + options: { type: string } + required: [ "default" ] +required: + - tmpOptions diff --git a/calamares/src/modules/fstab/main.py b/calamares/src/modules/fstab/main.py new file mode 100755 index 0000000..bd7ae77 --- /dev/null +++ b/calamares/src/modules/fstab/main.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Aurélien Gâteau +# SPDX-FileCopyrightText: 2016 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import os +import re +import copy + +import libcalamares + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Writing fstab.") + + +FSTAB_HEADER = """# /etc/fstab: static file system information. +# +# Use 'blkid' to print the universally unique identifier for a device; this may +# be used with UUID= as a more robust way to name devices that works even if +# disks are added and removed. See fstab(5). +# +# """ + +CRYPTTAB_HEADER = """# /etc/crypttab: mappings for encrypted partitions. +# +# Each mapped device will be created in /dev/mapper, so your /etc/fstab +# should use the /dev/mapper/ paths for encrypted devices. +# +# See crypttab(5) for the supported syntax. +# +# NOTE: You need not list your root (/) partition here, but it must be set up +# beforehand by the initramfs (/etc/mkinitcpio.conf). The same applies +# to encrypted swap, which should be set up with mkinitcpio-openswap +# for resume support. +# +# """ + +# Turn Parted filesystem names into fstab names +FS_MAP = { + "fat16": "vfat", + "fat32": "vfat", + "linuxswap": "swap", +} + + +def mkdir_p(path): + """ Create directory. + + :param path: + """ + if not os.path.exists(path): + os.makedirs(path) + + +def is_ssd_disk(disk_name): + """ Checks if given disk is actually a ssd disk. + + :param disk_name: + :return: + """ + filename = os.path.join("/sys/block", disk_name, "queue/rotational") + + if not os.path.exists(filename): + # Should not happen unless sysfs changes, but better safe than sorry + return False + + with open(filename) as sysfile: + return sysfile.read() == "0\n" + + +def disk_name_for_partition(partition): + """ Returns disk name for each found partition. + + :param partition: + :return: + """ + name = os.path.basename(partition["device"]) + + if name.startswith("mmcblk") or name.startswith("nvme"): + # Typical mmc device is mmcblk0p1, nvme looks like nvme0n1p2 + return re.sub("p[0-9]+$", "", name) + + return re.sub("[0-9]+$", "", name) + + +class FstabGenerator(object): + def __init__(self, partitions, root_mount_point, mount_options_list, + crypttab_options, tmp_options): + self.partitions = partitions + self.root_mount_point = root_mount_point + self.mount_options_list = mount_options_list + self.crypttab_options = crypttab_options + self.tmp_options = tmp_options + self.ssd_disks = set() + self.root_is_ssd = False + + def run(self): + """ Calls needed sub routines. + + :return: + """ + self.find_ssd_disks() + self.generate_fstab() + self.generate_crypttab() + self.create_mount_points() + + return None + + def find_ssd_disks(self): + """ Checks for ssd disks """ + disks = {disk_name_for_partition(x) for x in self.partitions} + self.ssd_disks = {x for x in disks if is_ssd_disk(x)} + + def generate_crypttab(self): + """ Create crypttab. """ + mkdir_p(os.path.join(self.root_mount_point, "etc")) + crypttab_path = os.path.join(self.root_mount_point, "etc", "crypttab") + + with open(crypttab_path, "w") as crypttab_file: + print(CRYPTTAB_HEADER, file=crypttab_file) + + for partition in self.partitions: + dct = self.generate_crypttab_line_info(partition) + + if dct: + self.print_crypttab_line(dct, file=crypttab_file) + + def generate_crypttab_line_info(self, partition): + """ Generates information for each crypttab entry. """ + if "luksMapperName" not in partition or "luksUuid" not in partition: + return None + + mapper_name = partition["luksMapperName"] + luks_uuid = partition["luksUuid"] + if not mapper_name or not luks_uuid: + return None + + crypttab_options = self.crypttab_options + # Make sure to not use missing keyfile + if os.path.isfile(os.path.join(self.root_mount_point, "crypto_keyfile.bin")): + password = "/crypto_keyfile.bin" + else: + password = "none" + + # Set crypttab password for partition to none and remove crypttab options + # if root partition was not encrypted + if any([p["mountPoint"] == "/" + and "luksMapperName" not in p + for p in self.partitions]): + password = "none" + crypttab_options = "" + # on root partition when /boot is unencrypted + elif partition["mountPoint"] == "/": + if any([p["mountPoint"] == "/boot" + and "luksMapperName" not in p + for p in self.partitions]): + password = "none" + crypttab_options = "" + + return dict( + name=mapper_name, + device="UUID=" + luks_uuid, + password=password, + options=crypttab_options, + ) + + def print_crypttab_line(self, dct, file=None): + """ Prints line to '/etc/crypttab' file. """ + line = "{:21} {:<45} {} {}".format(dct["name"], + dct["device"], + dct["password"], + dct["options"], + ) + + print(line, file=file) + + def generate_fstab(self): + """ Create fstab. """ + mkdir_p(os.path.join(self.root_mount_point, "etc")) + fstab_path = os.path.join(self.root_mount_point, "etc", "fstab") + + with open(fstab_path, "w") as fstab_file: + print(FSTAB_HEADER, file=fstab_file) + + for partition in self.partitions: + # Special treatment for a btrfs subvolumes + if (partition["fs"] == "btrfs" + and partition["mountPoint"] == "/"): + # Subvolume list has been created in mount.conf and curated in mount module, + # so all subvolumes here should be safe to add to fstab + btrfs_subvolumes = libcalamares.globalstorage.value("btrfsSubvolumes") + for s in btrfs_subvolumes: + mount_entry = copy.deepcopy(partition) + mount_entry["mountPoint"] = s["mountPoint"] + mount_entry["subvol"] = s["subvolume"] + dct = self.generate_fstab_line_info(mount_entry) + if dct: + self.print_fstab_line(dct, file=fstab_file) + elif partition["fs"] != "zfs": # zfs partitions don't need an entry in fstab + dct = self.generate_fstab_line_info(partition) + if dct: + self.print_fstab_line(dct, file=fstab_file) + + if self.root_is_ssd: + # Old behavior was to mount /tmp as tmpfs + # New behavior is to use tmpOptions to decide + # if mounting /tmp as tmpfs and which options to use + ssd = self.tmp_options.get("ssd", {}) + if not ssd: + ssd = self.tmp_options.get("default", {}) + # Default to True to mimic old behavior + tmpfs = ssd.get("tmpfs", True) + + if tmpfs: + options = ssd.get("options", "defaults,noatime,mode=1777") + # Mount /tmp on a tmpfs + dct = dict(device="tmpfs", + mount_point="/tmp", + fs="tmpfs", + options=options, + check=0, + ) + self.print_fstab_line(dct, file=fstab_file) + + def generate_fstab_line_info(self, partition): + """ + Generates information (a dictionary of fstab-fields) + for the given @p partition. + """ + # Some "fs" names need special handling in /etc/fstab, so remap them. + filesystem = partition["fs"].lower() + filesystem = FS_MAP.get(filesystem, filesystem) + luks_mapper_name = partition.get("luksMapperName", None) + mount_point = partition["mountPoint"] + disk_name = disk_name_for_partition(partition) + is_ssd = disk_name in self.ssd_disks + + # Swap partitions are called "linuxswap" by parted. + # That "fs" is visible in GS, but that gets mapped + # to "swap", above, because that's the spelling needed in /etc/fstab + if not mount_point and not filesystem == "swap": + return None + if not mount_point: + mount_point = "swap" + + if filesystem == "swap" and not partition.get("claimed", None): + libcalamares.utils.debug("Ignoring foreign swap {!s} {!s}".format(disk_name, partition.get("uuid", None))) + return None + + options = self.get_mount_options(mount_point) + + if mount_point == "/" and filesystem != "btrfs": + check = 1 + elif mount_point and mount_point != "swap" and filesystem != "btrfs": + check = 2 + else: + check = 0 + + if mount_point == "/": + self.root_is_ssd = is_ssd + + # If there's a set-and-not-empty subvolume set, add it + if filesystem == "btrfs" and partition.get("subvol",None): + options = "subvol={},".format(partition["subvol"]) + options + + device = None + if luks_mapper_name: + device = "/dev/mapper/" + luks_mapper_name + elif partition["uuid"]: + device = "UUID=" + partition["uuid"] + else: + device = partition["device"] + + if not device: + # TODO: we get here when the user mounted a previously encrypted partition + # This should be catched early in the process + return None + + return dict(device=device, + mount_point=mount_point, + fs=filesystem, + options=options, + check=check, + ) + + def print_fstab_line(self, dct, file=None): + """ Prints line to '/etc/fstab' file. """ + line = "{:41} {:<14} {:<7} {:<10} 0 {}".format(dct["device"], + dct["mount_point"], + dct["fs"], + dct["options"], + dct["check"], + ) + print(line, file=file) + + def create_mount_points(self): + """ Creates mount points """ + for partition in self.partitions: + if partition["mountPoint"]: + mkdir_p(self.root_mount_point + partition["mountPoint"]) + + def get_mount_options(self, mountpoint): + """ + Returns the mount options for a given mountpoint + + :param mountpoint: A string containing the mountpoint for the fstab entry + :return: A string containing the mount options for the entry or "defaults" if nothing is found + """ + mount_options_item = next((x for x in self.mount_options_list if x.get("mountpoint") == mountpoint), None) + if mount_options_item: + return mount_options_item.get("option_string", "defaults") + else: + return "defaults" + + +def create_swapfile(root_mount_point, root_btrfs): + """ + Creates /swapfile in @p root_mount_point ; if the root filesystem + is on btrfs, then handle some btrfs specific features as well, + as documented in + https://wiki.archlinux.org/index.php/Swap#Swap_file + + The swapfile-creation covers progress from 0.2 to 0.5 + """ + libcalamares.job.setprogress(0.2) + if root_btrfs: + # btrfs swapfiles must reside on a subvolume that is not snapshotted to prevent file system corruption + swapfile_path = os.path.join(root_mount_point, "swap/swapfile") + with open(swapfile_path, "wb") as f: + pass + libcalamares.utils.host_env_process_output(["chattr", "+C", "+m", swapfile_path]) # No Copy-on-Write, no compression + else: + swapfile_path = os.path.join(root_mount_point, "swapfile") + with open(swapfile_path, "wb") as f: + pass + # Create the swapfile; swapfiles are small-ish + zeroes = bytes(16384) + with open(swapfile_path, "wb") as f: + total = 0 + desired_size = 512 * 1024 * 1024 # 512MiB + while total < desired_size: + chunk = f.write(zeroes) + if chunk < 1: + libcalamares.utils.debug("Short write on {!s}, cancelling.".format(swapfile_path)) + break + libcalamares.job.setprogress(0.2 + 0.3 * ( total / desired_size ) ) + total += chunk + os.chmod(swapfile_path, 0o600) + libcalamares.utils.host_env_process_output(["mkswap", swapfile_path]) + libcalamares.job.setprogress(0.5) + + +def run(): + """ Configures fstab. + + :return: + """ + global_storage = libcalamares.globalstorage + conf = libcalamares.job.configuration + partitions = global_storage.value("partitions") + root_mount_point = global_storage.value("rootMountPoint") + + if not partitions: + libcalamares.utils.warning("partitions is empty, {!s}" + .format(partitions)) + return (_("Configuration Error"), + _("No partitions are defined for
{!s}
to use.") + .format("fstab")) + if not root_mount_point: + libcalamares.utils.warning("rootMountPoint is empty, {!s}" + .format(root_mount_point)) + return (_("Configuration Error"), + _("No root mount point is given for
{!s}
to use.") + .format("fstab")) + + # This follows the GS settings from the partition module's Config object + swap_choice = global_storage.value( "partitionChoices" ) + if swap_choice: + swap_choice = swap_choice.get( "swap", None ) + if swap_choice and swap_choice == "file": + # There's no formatted partition for it, so we'll sneak in an entry + root_partitions = [ p["fs"].lower() for p in partitions if p["mountPoint"] == "/" ] + root_btrfs = (root_partitions[0] == "btrfs") if root_partitions else False + if root_btrfs: + partitions.append( dict(fs="swap", mountPoint=None, claimed=True, device="/swap/swapfile", uuid=None) ) + else: + partitions.append( dict(fs="swap", mountPoint=None, claimed=True, device="/swapfile", uuid=None) ) + else: + swap_choice = None + + libcalamares.job.setprogress(0.1) + mount_options_list = global_storage.value("mountOptionsList") + crypttab_options = conf.get("crypttabOptions", "luks") + tmp_options = conf.get("tmpOptions", {}) + + # We rely on mount_options having a default; if there wasn't one, + # bail out with a meaningful error. + if not mount_options_list: + libcalamares.utils.warning("No mount options defined, {!s} partitions".format(len(partitions))) + return (_("Configuration Error"), + _("No
{!s}
configuration is given for
{!s}
to use.") + .format("mountOptions", "fstab")) + + generator = FstabGenerator(partitions, + root_mount_point, + mount_options_list, + crypttab_options, + tmp_options) + + if swap_choice is not None: + libcalamares.job.setprogress(0.2) + root_partitions = [ p["fs"].lower() for p in partitions if p["mountPoint"] == "/" ] + root_btrfs = (root_partitions[0] == "btrfs") if root_partitions else False + create_swapfile(root_mount_point, root_btrfs) + + try: + libcalamares.job.setprogress(0.5) + return generator.run() + finally: + libcalamares.job.setprogress(1.0) diff --git a/calamares/src/modules/fstab/module.desc b/calamares/src/modules/fstab/module.desc new file mode 100644 index 0000000..77cb7ad --- /dev/null +++ b/calamares/src/modules/fstab/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "fstab" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/fstab/test.yaml b/calamares/src/modules/fstab/test.yaml new file mode 100644 index 0000000..cd20345 --- /dev/null +++ b/calamares/src/modules/fstab/test.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +rootMountPoint: /tmp/mount +partitions: + - device: /dev/sda1 + fs: ext4 + mountPoint: / + uuid: 2a00f1d5-1217-49a7-bedd-b55c85764732 + - device: /dev/sda2 + fs: swap + uuid: 59406569-446f-4730-a874-9f6b4b44fee3 + mountPoint: + - device: /dev/sdb1 + fs: btrfs + mountPoint: /home + uuid: 59406569-abcd-1234-a874-9f6b4b44fee3 diff --git a/calamares/src/modules/fstab/test2.yaml b/calamares/src/modules/fstab/test2.yaml new file mode 100644 index 0000000..0e91bf6 --- /dev/null +++ b/calamares/src/modules/fstab/test2.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# This test shows how btrfs root would work +rootMountPoint: /tmp/mount +partitions: + - device: /dev/sda1 + fs: btrfs + mountPoint: / + uuid: 2a00f1d5-1217-49a7-bedd-b55c85764732 + - device: /dev/sda2 + fs: swap + uuid: 59406569-446f-4730-a874-9f6b4b44fee3 + mountPoint: + - device: /dev/sdb1 + fs: btrfs + mountPoint: /home + uuid: 59406569-abcd-1234-a874-9f6b4b44fee3 +btrfsSubvolumes: + - mountPoint: / + subvolume: "@ROOT" + - mountPoint: /var + subvolume: "@var" + - mountPoint: /usr/local + subvolume: "@local" diff --git a/calamares/src/modules/grubcfg/grubcfg.conf b/calamares/src/modules/grubcfg/grubcfg.conf new file mode 100644 index 0000000..2212d27 --- /dev/null +++ b/calamares/src/modules/grubcfg/grubcfg.conf @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Create, overwrite or update /etc/default/grub in the target system. +# +# Write lines to /etc/default/grub (in the target system) based +# on calculated values and the values set in the *defaults* key +# in this configuration file. +# +# Calculated values are: +# - GRUB_DISTRIBUTOR, branding module, *bootloaderEntryName* (this +# string is sanitized, and see also setting *keep_distributor*) +# - GRUB_ENABLE_CRYPTODISK, based on the presence of filesystems +# that use LUKS +# - GRUB_CMDLINE_LINUX_DEFAULT, adding LUKS setup and plymouth +# support to the kernel. + +--- +# If set to true, always creates /etc/default/grub from scratch even if the file +# already existed. If set to false, edits the existing file instead. +overwrite: false + +# If set to true, prefer to write files in /etc/default/grub.d/ +# rather than the single file /etc/default/grub. If this is set, +# Calamares will write /etc/default/grub.d/00calamares.cfg instead. +prefer_grub_d: false + +# If set to true, an **existing** setting for GRUB_DISTRIBUTOR is +# kept, not updated to the *bootloaderEntryName* from the branding file. +# Use this if the GRUB_DISTRIBUTOR setting in the file is "smart" in +# some way (e.g. uses shell-command substitution). +keep_distributor: false + +# The default kernel params that should always be applied. +# This is an array of strings. If it is unset, the default is +# `["quiet"]`. To avoid the default, explicitly set this key +# to an empty list, `[]`. +kernel_params: [ "quiet" ] + +# Default entries to write to /etc/default/grub if it does not exist yet or if +# we are overwriting it. +# +defaults: + GRUB_TIMEOUT: 5 + GRUB_DEFAULT: "saved" + GRUB_DISABLE_SUBMENU: true + GRUB_TERMINAL_OUTPUT: "console" + GRUB_DISABLE_RECOVERY: true + +# Set to true to force defaults to be used even when not overwriting +always_use_defaults: false diff --git a/calamares/src/modules/grubcfg/grubcfg.schema.yaml b/calamares/src/modules/grubcfg/grubcfg.schema.yaml new file mode 100644 index 0000000..c04c0b7 --- /dev/null +++ b/calamares/src/modules/grubcfg/grubcfg.schema.yaml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/grubcfg +additionalProperties: false +type: object +properties: + overwrite: { type: boolean, default: false } + keep_distributor: { type: boolean, default: false } + prefer_grub_d: { type: boolean, default: false } + kernel_params: { type: array, items: { type: string } } + defaults: + type: object + additionalProperties: true # Other fields are acceptable + properties: + GRUB_TIMEOUT: { type: integer } + GRUB_DEFAULT: { type: string } + GRUB_DISABLE_SUBMENU: { type: boolean, default: true } + GRUB_TERMINAL_OUTPUT: { type: string } + GRUB_DISABLE_RECOVERY: { type: boolean, default: true } + required: [ GRUB_TIMEOUT, GRUB_DEFAULT ] + always_use_defaults: { type: boolean, default: false } diff --git a/calamares/src/modules/grubcfg/main.py b/calamares/src/modules/grubcfg/main.py new file mode 100644 index 0000000..346a267 --- /dev/null +++ b/calamares/src/modules/grubcfg/main.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014-2015 Philip Müller +# SPDX-FileCopyrightText: 2015-2017 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot +# SPDX-FileCopyrightText: 2017-2018 Gabriel Craciunescu +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import libcalamares +import fileinput +import os +import re +import shutil + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Configure GRUB.") + + +def get_grub_config_path(root_mount_point): + """ + Figures out where to put the grub config files. Returns + a the full path of a file inside that + directory, as "the config file". + + Returns a path into @p root_mount_point. + """ + default_dir = os.path.join(root_mount_point, "etc/default") + default_config_file = "grub" + + if "prefer_grub_d" in libcalamares.job.configuration and libcalamares.job.configuration["prefer_grub_d"]: + possible_dir = os.path.join(root_mount_point, "etc/default/grub.d") + if os.path.exists(possible_dir) and os.path.isdir(possible_dir): + default_dir = possible_dir + default_config_file = "00calamares.cfg" + + if not os.path.exists(default_dir): + try: + os.mkdir(default_dir) + except Exception as error: + # exception as error is still redundant, but it print out the error + # identify a solution for each exception and + # if possible and code it within. + libcalamares.utils.debug(f"Failed to create {default_dir}") + libcalamares.utils.debug(f"{error}") + raise + + return os.path.join(default_dir, default_config_file) + + +def get_zfs_root(): + """ + Looks in global storage to find the zfs root + + :return: A string containing the path to the zfs root or None if it is not found + """ + + zfs = libcalamares.globalstorage.value("zfsDatasets") + + if not zfs: + libcalamares.utils.warning("Failed to locate zfs dataset list") + return None + + # Find the root dataset + for dataset in zfs: + try: + if dataset["mountpoint"] == "/": + return dataset["zpool"] + "/" + dataset["dsName"] + except KeyError: + # This should be impossible + libcalamares.utils.warning("Internal error handling zfs dataset") + raise + + return None + + +def update_existing_config(default_grub, grub_config_items): + """ + Updates the existing grub configuration file with any items present in @p grub_config_items + + Items that exist in the file will be updated and new items will be appended to the end + + :param default_grub: The absolute path to the grub config file + :param grub_config_items: A dict holding the key value pairs representing the items + """ + + default_grub_orig = default_grub + ".calamares" + shutil.move(default_grub, default_grub_orig) + + with open(default_grub, "w") as grub_file: + with open(default_grub_orig, "r") as grub_orig_file: + for line in grub_orig_file.readlines(): + line = line.strip() + if "=" in line: + # This may be a key, strip the leading comment if it has one + key = line.lstrip("#").split("=")[0].strip() + + # check if this is noe of the keys we care about + if key in grub_config_items.keys(): + print(f"{key}={grub_config_items[key]}", file=grub_file) + del grub_config_items[key] + else: + print(line, file=grub_file) + else: + print(line, file=grub_file) + + if len(grub_config_items) != 0: + for dict_key, dict_val in grub_config_items.items(): + print(f"{dict_key}={dict_val}", file=grub_file) + + os.remove(default_grub_orig) + + +def modify_grub_default(partitions, root_mount_point, distributor): + """ + Configures '/etc/default/grub' for hibernation and plymouth. + + @see bootloader/main.py, for similar handling of kernel parameters + + :param partitions: + :param root_mount_point: + :param distributor: name of the distributor to fill in for + GRUB_DISTRIBUTOR. Must be a string. If the job setting + *keep_distributor* is set, then this is only used if no + GRUB_DISTRIBUTOR is found at all (otherwise, when *keep_distributor* + is set, the GRUB_DISTRIBUTOR lines are left unchanged). + If *keep_distributor* is unset or false, then GRUB_DISTRIBUTOR + is always updated to set this value. + :return: + """ + default_grub = get_grub_config_path(root_mount_point) + distributor = distributor.replace("'", "'\\''") + dracut_bin = libcalamares.utils.target_env_call( + ["sh", "-c", "which dracut"] + ) + plymouth_bin = libcalamares.utils.target_env_call( + ["sh", "-c", "which plymouth"] + ) + uses_systemd_hook = libcalamares.utils.target_env_call( + ["sh", "-c", "grep -q \"^HOOKS.*systemd\" /etc/mkinitcpio.conf"] + ) == 0 + # Shell exit value 0 means success + have_plymouth = plymouth_bin == 0 + use_systemd_naming = dracut_bin == 0 or uses_systemd_hook + + use_splash = "" + swap_uuid = "" + swap_outer_uuid = "" + swap_outer_mappername = None + no_save_default = False + unencrypted_separate_boot = any(p["mountPoint"] == "/boot" and "luksMapperName" not in p for p in partitions) + # If there is no dracut, and the root partition is ZFS, this gets set below + zfs_root_path = None + + for partition in partitions: + if partition["mountPoint"] in ("/", "/boot") and partition["fs"] in ("btrfs", "f2fs", "zfs"): + no_save_default = True + break + + if have_plymouth: + use_splash = "splash" + + cryptdevice_params = [] + + if use_systemd_naming: + for partition in partitions: + if partition["fs"] == "linuxswap" and not partition.get("claimed", None): + # Skip foreign swap + continue + has_luks = "luksMapperName" in partition + if partition["fs"] == "linuxswap" and not has_luks: + swap_uuid = partition["uuid"] + + if partition["fs"] == "linuxswap" and has_luks: + swap_outer_uuid = partition["luksUuid"] + swap_outer_mappername = partition["luksMapperName"] + + if partition["mountPoint"] == "/" and has_luks: + cryptdevice_params = [f"rd.luks.uuid={partition['luksUuid']}"] + if not unencrypted_separate_boot and uses_systemd_hook: + cryptdevice_params.append("rd.luks.key=/crypto_keyfile.bin") + else: + for partition in partitions: + if partition["fs"] == "linuxswap" and not partition.get("claimed", None): + # Skip foreign swap + continue + has_luks = "luksMapperName" in partition + if partition["fs"] == "linuxswap" and not has_luks: + swap_uuid = partition["uuid"] + + if partition["fs"] == "linuxswap" and has_luks: + swap_outer_mappername = partition["luksMapperName"] + + if partition["mountPoint"] == "/" and has_luks: + cryptdevice_params = [ + f"cryptdevice=UUID={partition['luksUuid']}:{partition['luksMapperName']}", + f"root=/dev/mapper/{partition['luksMapperName']}" + ] + + if partition["fs"] == "zfs" and partition["mountPoint"] == "/": + zfs_root_path = get_zfs_root() + + kernel_params = libcalamares.job.configuration.get("kernel_params", ["quiet"]) + + # Currently, grub doesn't detect this properly so it must be set manually + if zfs_root_path: + kernel_params.insert(0, "zfs=" + zfs_root_path) + + if cryptdevice_params: + kernel_params.extend(cryptdevice_params) + + if use_splash: + kernel_params.append(use_splash) + + if swap_uuid: + kernel_params.append(f"resume=UUID={swap_uuid}") + + if use_systemd_naming and swap_outer_uuid: + kernel_params.append(f"rd.luks.uuid={swap_outer_uuid}") + if swap_outer_mappername: + kernel_params.append(f"resume=/dev/mapper/{swap_outer_mappername}") + + overwrite = libcalamares.job.configuration.get("overwrite", False) + + grub_config_items = {} + # read the lines we need from the existing config + if os.path.exists(default_grub) and not overwrite: + with open(default_grub, 'r') as grub_file: + lines = [x.strip() for x in grub_file.readlines()] + + for line in lines: + if line.startswith("GRUB_CMDLINE_LINUX_DEFAULT"): + existing_params = re.sub(r"^GRUB_CMDLINE_LINUX_DEFAULT\s*=\s*", "", line).strip("\"'").split() + + for existing_param in existing_params: + existing_param_name = existing_param.split("=")[0].strip() + + # Ensure we aren't adding duplicated params + param_exists = False + for param in kernel_params: + if param.split("=")[0].strip() == existing_param_name: + param_exists = True + break + if not param_exists and existing_param_name not in ["quiet", "resume", "splash"]: + kernel_params.append(existing_param) + + elif line.startswith("GRUB_DISTRIBUTOR") and libcalamares.job.configuration.get("keep_distributor", False): + distributor_parts = line.split("=") + if len(distributor_parts) > 1: + distributor = distributor_parts[1].strip("'\"") + + # If a filesystem grub can't write to is used, disable save default + if no_save_default and line.strip().startswith("GRUB_SAVEDEFAULT"): + grub_config_items["GRUB_SAVEDEFAULT"] = "false" + + always_use_defaults = libcalamares.job.configuration.get("always_use_defaults", False) + + # If applicable add the items from defaults to the dict containing the grub config to wirte/modify + if always_use_defaults or overwrite or not os.path.exists(default_grub): + if "defaults" in libcalamares.job.configuration: + for key, value in libcalamares.job.configuration["defaults"].items(): + if isinstance(value, bool): + if value: + escaped_value = "true" + else: + escaped_value = "false" + else: + escaped_value = str(value).replace("'", "'\\''") + + grub_config_items[key] = f"'{escaped_value}'" + + grub_config_items['GRUB_CMDLINE_LINUX_DEFAULT'] = f"'{' '.join(kernel_params)}'" + grub_config_items["GRUB_DISTRIBUTOR"] = f"'{distributor}'" + + if cryptdevice_params and not unencrypted_separate_boot: + grub_config_items["GRUB_ENABLE_CRYPTODISK"] = "y" + + if overwrite or not os.path.exists(default_grub) or libcalamares.job.configuration.get("prefer_grub_d", False): + with open(default_grub, 'w') as grub_file: + for key, value in grub_config_items.items(): + grub_file.write(f"{key}={value}\n") + else: + update_existing_config(default_grub, grub_config_items) + + return None + + +def run(): + """ + Calls routine with given parameters to modify '/etc/default/grub'. + + :return: + """ + fw_type = libcalamares.globalstorage.value("firmwareType") + partitions = libcalamares.globalstorage.value("partitions") + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + branding = libcalamares.globalstorage.value("branding") + if branding is None: + distributor = None + else: + distributor = branding["bootloaderEntryName"] + + if libcalamares.globalstorage.value("bootLoader") is None and fw_type != "efi": + return None + + if fw_type == "efi": + esp_found = False + + for partition in partitions: + if partition["mountPoint"] == libcalamares.globalstorage.value("efiSystemPartition"): + esp_found = True + + if not esp_found: + return None + + return modify_grub_default(partitions, root_mount_point, distributor) diff --git a/calamares/src/modules/grubcfg/module.desc b/calamares/src/modules/grubcfg/module.desc new file mode 100644 index 0000000..293e75b --- /dev/null +++ b/calamares/src/modules/grubcfg/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "grubcfg" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/grubcfg/tests/1.global b/calamares/src/modules/grubcfg/tests/1.global new file mode 100644 index 0000000..5049310 --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/1.global @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +branding: + bootloaderEntryName: generic diff --git a/calamares/src/modules/grubcfg/tests/2.global b/calamares/src/modules/grubcfg/tests/2.global new file mode 100644 index 0000000..1e01c6b --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/2.global @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +firmwareType: bios +bootLoader: grub +rootMountPoint: /tmp/calamares/grubcfg-test-2 + +branding: + bootloaderEntryName: generic +partitions: [] + diff --git a/calamares/src/modules/grubcfg/tests/2.job b/calamares/src/modules/grubcfg/tests/2.job new file mode 100644 index 0000000..5265ef5 --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/2.job @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +overwrite: true +keep_distributor: false +defaults: + GRUB_TIMEOUT: 5 + GRUB_DEFAULT: "saved" + GRUB_DISABLE_SUBMENU: true + GRUB_TERMINAL_OUTPUT: "console" + GRUB_DISABLE_RECOVERY: true diff --git a/calamares/src/modules/grubcfg/tests/3.global b/calamares/src/modules/grubcfg/tests/3.global new file mode 100644 index 0000000..3eda6d5 --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/3.global @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +firmwareType: bios +bootLoader: grub +rootMountPoint: /tmp/calamares/grubcfg-test-3 + +branding: + bootloaderEntryName: generic +partitions: [] + diff --git a/calamares/src/modules/grubcfg/tests/3.job b/calamares/src/modules/grubcfg/tests/3.job new file mode 100644 index 0000000..94f3943 --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/3.job @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +overwrite: true +prefer_grub_d: true # But it doesn't exist +keep_distributor: false +defaults: + GRUB_TIMEOUT: 5 + GRUB_DEFAULT: "saved" + GRUB_DISABLE_SUBMENU: true + GRUB_TERMINAL_OUTPUT: "console" + GRUB_DISABLE_RECOVERY: true diff --git a/calamares/src/modules/grubcfg/tests/4.global b/calamares/src/modules/grubcfg/tests/4.global new file mode 100644 index 0000000..7d45795 --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/4.global @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +firmwareType: bios +bootLoader: grub +rootMountPoint: /tmp/calamares/grubcfg-test-4 + +branding: + bootloaderEntryName: generic +partitions: [] + diff --git a/calamares/src/modules/grubcfg/tests/4.job b/calamares/src/modules/grubcfg/tests/4.job new file mode 100644 index 0000000..4fdc2e2 --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/4.job @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +overwrite: true +prefer_grub_d: true +keep_distributor: false +defaults: + GRUB_TIMEOUT: 5 + GRUB_DEFAULT: "saved" + GRUB_DISABLE_SUBMENU: true + GRUB_TERMINAL_OUTPUT: "console" + GRUB_DISABLE_RECOVERY: true diff --git a/calamares/src/modules/grubcfg/tests/CMakeTests.txt b/calamares/src/modules/grubcfg/tests/CMakeTests.txt new file mode 100644 index 0000000..7ecfffc --- /dev/null +++ b/calamares/src/modules/grubcfg/tests/CMakeTests.txt @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Special cases for grubcfg configuration tests: +# - 2.global specifies /tmp/calamares as the rootMountPath, +# so we end up editing files there. Create the directory +# beforehand, so the test doesn't blow up. + +add_test( + NAME make-grubcfg-dirs + COMMAND + ${CMAKE_COMMAND} -E make_directory /tmp/calamares/grubcfg-test-2/etc/default + /tmp/calamares/grubcfg-test-3/etc/default /tmp/calamares/grubcfg-test-4/etc/default/grub.d +) +set_tests_properties(load-grubcfg-2 PROPERTIES DEPENDS make-grubcfg-dirs) +set_tests_properties(load-grubcfg-3 PROPERTIES DEPENDS make-grubcfg-dirs) +set_tests_properties(load-grubcfg-4 PROPERTIES DEPENDS make-grubcfg-dirs) diff --git a/calamares/src/modules/hostinfo/CMakeLists.txt b/calamares/src/modules/hostinfo/CMakeLists.txt new file mode 100644 index 0000000..d432bd9 --- /dev/null +++ b/calamares/src/modules/hostinfo/CMakeLists.txt @@ -0,0 +1,37 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# Configuration for hostinfo +# +# There isn't anything to configure for the hostinfo module. +# +# Hostinfo puts information about the host system into Calamares +# GlobalStorage. This information is generally unchanging. Put +# this module somewhere early in the exec: section to pick up +# the variables. Use a contextualprocess module later to +# react to the values, if needed. +# +# GlobalStorage keys: +# +# - *hostOS* the OS this module was built under; value is "Linux" or +# "FreeBSD" or blank. +# - *hostOSName* the NAME value from /etc/os-release if it exists, +# otherwise the same as *hostOS*. +# - *hostCPU* the make (brand) of the CPU, if it can be determined. +# Values are "Intel" or "AMD" or blank. + +calamares_add_plugin(hostinfo + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + HostInfoJob.cpp + SHARED_LIB + NO_CONFIG +) + +target_link_libraries(calamares_job_hostinfo PRIVATE ${kfname}::CoreAddons) + +calamares_add_test(hostinfotest SOURCES Tests.cpp HostInfoJob.cpp LIBRARIES yamlcpp::yamlcpp) diff --git a/calamares/src/modules/hostinfo/HostInfoJob.cpp b/calamares/src/modules/hostinfo/HostInfoJob.cpp new file mode 100644 index 0000000..51de1fa --- /dev/null +++ b/calamares/src/modules/hostinfo/HostInfoJob.cpp @@ -0,0 +1,190 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "HostInfoJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Units.h" + +#include +#include + +#include + +#ifdef Q_OS_FREEBSD +#include + +#include +#endif + +HostInfoJob::HostInfoJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +HostInfoJob::~HostInfoJob() {} + +QString +HostInfoJob::prettyName() const +{ + return tr( "Collecting information about your machine…", "@status" ); +} + +QString +hostOS() +{ +#if defined( Q_OS_FREEBSD ) + return QStringLiteral( "FreeBSD" ); +#elif defined( Q_OS_LINUX ) + return QStringLiteral( "Linux" ); +#else + return QStringLiteral( "" ); +#endif +} + +QString +hostOSName() +{ + KOSRelease r; + if ( !r.name().isEmpty() ) + { + return r.name(); + } + return hostOS(); +} + +static QString +hostCPUmatch( const QString& s ) +{ + const QString line = s.toLower(); + if ( line.contains( "intel" ) ) + { + return QStringLiteral( "Intel" ); + } + else if ( line.contains( "amd" ) ) + { + return QStringLiteral( "AMD" ); + } + return QString(); +} + +#if defined( Q_OS_FREEBSD ) +QString +hostCPU_FreeBSD() +{ + constexpr const size_t sysctl_buffer_size = 128; + char sysctl_buffer[ sysctl_buffer_size ]; + size_t s = sysctl_buffer_size; + + memset( sysctl_buffer, 0, sizeof( sysctl_buffer ) ); + int r = sysctlbyname( "hw.model", &sysctl_buffer, &s, NULL, 0 ); + if ( r ) + { + return QString(); + } + + sysctl_buffer[ sysctl_buffer_size - 1 ] = 0; + QString model( sysctl_buffer ); + return hostCPUmatch( model ); +} +#endif + +#if defined( Q_OS_LINUX ) +static QString +hostCPUmatchARM( const QString& ) +{ + /* The "CPU implementer" line is for ARM CPUs in general. + * + * The specific value given distinguishes *which designer* + * (or architecture licensee, who cares) produced the current + * silicon. For instance, a list from lscpu-arm.c (Linux kernel) + * shows this: + * + static const struct hw_impl hw_implementer[] = { + { 0x41, arm_part, "ARM" }, + { 0x42, brcm_part, "Broadcom" }, + { 0x43, cavium_part, "Cavium" }, + { 0x44, dec_part, "DEC" }, + { 0x48, hisi_part, "HiSilicon" }, + { 0x4e, nvidia_part, "Nvidia" }, + { 0x50, apm_part, "APM" }, + { 0x51, qcom_part, "Qualcomm" }, + { 0x53, samsung_part, "Samsung" }, + { 0x56, marvell_part, "Marvell" }, + { 0x66, faraday_part, "Faraday" }, + { 0x69, intel_part, "Intel" }, + { -1, unknown_part, "unknown" }, + }; + * + * Since the specific implementor isn't interesting, just + * map everything to "ARM". + */ + return QStringLiteral( "ARM" ); +} + +QString +hostCPU_Linux() +{ + QFile cpuinfo( "/proc/cpuinfo" ); + if ( cpuinfo.open( QIODevice::ReadOnly ) ) + { + QTextStream in( &cpuinfo ); + QString line; + while ( in.readLineInto( &line ) ) + { + if ( line.startsWith( "vendor_id" ) ) + { + return hostCPUmatch( line ); + } + if ( line.startsWith( "CPU implementer" ) ) + { + return hostCPUmatchARM( line ); + } + } + } + return QString(); // Not open, or not found +} +#endif + +QString +hostCPU() +{ +#if defined( Q_OS_FREEBSD ) + return hostCPU_FreeBSD(); +#elif defined( Q_OS_LINUX ) + return hostCPU_Linux(); +#else + return QString(); +#endif +} + +Calamares::JobResult +HostInfoJob::exec() +{ + cDebug() << "Collecting host information..."; + + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + gs->insert( "hostOS", hostOS() ); + gs->insert( "hostOSName", hostOSName() ); + gs->insert( "hostCPU", hostCPU() ); + + // Memory can't be negative, so it's reported as unsigned long. + auto ram = Calamares::BytesToMiB( qint64( Calamares::System::instance()->getTotalMemoryB().first ) ); + if ( ram ) + { + gs->insert( "hostRAMMiB", ram ); + } + + return Calamares::JobResult::ok(); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( HostInfoJobFactory, registerPlugin< HostInfoJob >(); ) diff --git a/calamares/src/modules/hostinfo/HostInfoJob.h b/calamares/src/modules/hostinfo/HostInfoJob.h new file mode 100644 index 0000000..b252da7 --- /dev/null +++ b/calamares/src/modules/hostinfo/HostInfoJob.h @@ -0,0 +1,55 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef HOSTINFOJOB_H +#define HOSTINFOJOB_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include + +/** @brief the compile-time host OS + * + * Returns "FreeBSD" or "Linux" or empty. + */ +QString hostOS(); + +/** @brief the run-time host OS + * + * Returns os-release NAME information, or if that is blank or not available, + * the same as hostOS(). + */ +QString hostOSName(); + +/** @brief the run-time CPU architecture + * + * Returns "Intel" or "AMD" or blank, if Calamares can determine what + * CPU is currently in use (based on /proc/cpuinfo or hw.model). + */ +QString hostCPU(); + +class PLUGINDLLEXPORT HostInfoJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit HostInfoJob( QObject* parent = nullptr ); + ~HostInfoJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( HostInfoJobFactory ) + +#endif // HOSTINFOJOB_H diff --git a/calamares/src/modules/hostinfo/Tests.cpp b/calamares/src/modules/hostinfo/Tests.cpp new file mode 100644 index 0000000..7635b42 --- /dev/null +++ b/calamares/src/modules/hostinfo/Tests.cpp @@ -0,0 +1,87 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "HostInfoJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" + +#include "utils/Logger.h" +#include "utils/Yaml.h" + +#include + +class HostInfoTests : public QObject +{ + Q_OBJECT +public: + HostInfoTests() {} + ~HostInfoTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testHostOS(); +}; + +void +HostInfoTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "HostInfo test started."; +} + +void +HostInfoTests::testHostOS() +{ +#if defined( Q_OS_FREEBSD ) + QString expect( "FreeBSD" ); +#elif defined( Q_OS_LINUX ) + QString expect( "Linux" ); +#else + QString expect( "Plan8" ); // Expect failure +#endif + + QCOMPARE( expect, hostOS() ); + // QCOMPARE( expect, hostOSName() ); // With KOSRelease, returns what the distro calls itself + + // This is a lousy test, too: the implementation reads /proc/cpuinfo + // and that's the only way we could use, too, to find what the "right" + // answer is. + QStringList x86cpunames { QStringLiteral( "Intel" ), QStringLiteral( "AMD" ) }; + QStringList armcpunames { QStringLiteral( "ARM" ) }; + const QString cpu = hostCPU(); + QVERIFY( x86cpunames.contains( cpu ) || armcpunames.contains( cpu ) ); + + // Try to detect family in a different way + QFile modalias( "/sys/devices/system/cpu/modalias" ); + if ( modalias.open( QIODevice::ReadOnly ) ) + { + QString cpumodalias = modalias.readLine(); + if ( cpumodalias.contains( "type:x86" ) ) + { + QVERIFY( x86cpunames.contains( cpu ) ); + } + else if ( cpumodalias.contains( "type:aarch64" ) ) + { + QVERIFY( armcpunames.contains( cpu ) ); + } + else + { + QCOMPARE( cpu, QString( "Unknown CPU modalias '%1'" ).arg( cpumodalias ) ); + } + } +} + + +QTEST_GUILESS_MAIN( HostInfoTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/hwclock/main.py b/calamares/src/modules/hwclock/main.py new file mode 100644 index 0000000..be9fabf --- /dev/null +++ b/calamares/src/modules/hwclock/main.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014-2015 Philip Müller +# SPDX-FileCopyrightText: 2014 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017-2018 Gabriel Craciunescu +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import libcalamares + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Setting hardware clock.") + + +def run(): + """ + Set hardware clock. + """ + hwclock_rtc = ["hwclock", "--systohc", "--utc"] + hwclock_isa = ["hwclock", "--systohc", "--utc", "--directisa"] + is_broken_rtc = False + is_broken_isa = False + + ret = libcalamares.utils.target_env_call(hwclock_rtc) + if ret != 0: + is_broken_rtc = True + libcalamares.utils.debug("Hwclock returned error code {}".format(ret)) + libcalamares.utils.debug(" .. RTC method failed, trying ISA bus method.") + else: + libcalamares.utils.debug("Hwclock set using RTC method.") + if is_broken_rtc: + ret = libcalamares.utils.target_env_call(hwclock_isa) + if ret != 0: + is_broken_isa = True + libcalamares.utils.debug("Hwclock returned error code {}".format(ret)) + libcalamares.utils.debug(" .. ISA bus method failed.") + else: + libcalamares.utils.debug("Hwclock set using ISA bus method.") + if is_broken_rtc and is_broken_isa: + libcalamares.utils.debug("BIOS or Kernel BUG: Setting hwclock failed.") + + return None diff --git a/calamares/src/modules/hwclock/module.desc b/calamares/src/modules/hwclock/module.desc new file mode 100644 index 0000000..d13435b --- /dev/null +++ b/calamares/src/modules/hwclock/module.desc @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "hwclock" +interface: "python" +script: "main.py" +noconfig: true diff --git a/calamares/src/modules/initcpio/CMakeLists.txt b/calamares/src/modules/initcpio/CMakeLists.txt new file mode 100644 index 0000000..1bbb9fd --- /dev/null +++ b/calamares/src/modules/initcpio/CMakeLists.txt @@ -0,0 +1,20 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(initcpio + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + InitcpioJob.cpp + SHARED_LIB +) + +calamares_add_test( + initcpiotest + SOURCES Tests.cpp + LIBRARIES + calamares_job_initcpio # From above + yamlcpp::yamlcpp +) diff --git a/calamares/src/modules/initcpio/InitcpioJob.cpp b/calamares/src/modules/initcpio/InitcpioJob.cpp new file mode 100644 index 0000000..ad569fa --- /dev/null +++ b/calamares/src/modules/initcpio/InitcpioJob.cpp @@ -0,0 +1,97 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2022 Evan James + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "InitcpioJob.h" + +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/UMask.h" +#include "utils/Variant.h" + +#include +#include + +InitcpioJob::InitcpioJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +InitcpioJob::~InitcpioJob() {} + +QString +InitcpioJob::prettyName() const +{ + return tr( "Creating initramfs with mkinitcpio…", "@status" ); +} + +/** @brief Sets secure permissions on each initramfs + * + * Iterates over each initramfs contained directly in the directory @p d. + * For each initramfs found, the permissions are set to owner read/write only. + * + */ +void +fixPermissions( const QDir& d ) +{ + const auto initramList = d.entryInfoList( { "initramfs*" }, QDir::Files ); + for ( const auto& fi : initramList ) + { + QFile f( fi.absoluteFilePath() ); + if ( f.exists() ) + { + cDebug() << "initcpio setting permissions for" << f.fileName(); + f.setPermissions( QFileDevice::ReadOwner | QFileDevice::WriteOwner ); + } + } +} + +Calamares::JobResult +InitcpioJob::exec() +{ + Calamares::UMask m( Calamares::UMask::Safe ); + + if ( m_unsafe ) + { + cDebug() << "Skipping mitigations for unsafe initramfs permissions."; + } + else + { + QDir d( Calamares::System::instance()->targetPath( "/boot" ) ); + if ( d.exists() ) + { + fixPermissions( d ); + } + } + + // If the kernel option isn't set to a specific kernel, run mkinitcpio on all kernels + QStringList command = { "mkinitcpio" }; + if ( m_kernel.isEmpty() || m_kernel == "all" ) + { + command.append( "-P" ); + } + else + { + command.append( { "-p", m_kernel } ); + } + + cDebug() << "Updating initramfs with kernel" << m_kernel; + auto r = Calamares::System::instance()->targetEnvCommand( command, QString(), QString() /* no timeout , 0 */ ); + return r.explainProcess( "mkinitcpio", std::chrono::seconds( 10 ) /* fake timeout */ ); +} + +void +InitcpioJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_kernel = Calamares::getString( configurationMap, "kernel" ); + + m_unsafe = Calamares::getBool( configurationMap, "be_unsafe", false ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( InitcpioJobFactory, registerPlugin< InitcpioJob >(); ) diff --git a/calamares/src/modules/initcpio/InitcpioJob.h b/calamares/src/modules/initcpio/InitcpioJob.h new file mode 100644 index 0000000..6e7f2b5 --- /dev/null +++ b/calamares/src/modules/initcpio/InitcpioJob.h @@ -0,0 +1,41 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef INITCPIOJOB_H +#define INITCPIOJOB_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include + +class PLUGINDLLEXPORT InitcpioJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit InitcpioJob( QObject* parent = nullptr ); + ~InitcpioJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_kernel; + bool m_unsafe = false; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( InitcpioJobFactory ) + +#endif // INITCPIOJOB_H diff --git a/calamares/src/modules/initcpio/Tests.cpp b/calamares/src/modules/initcpio/Tests.cpp new file mode 100644 index 0000000..bff163b --- /dev/null +++ b/calamares/src/modules/initcpio/Tests.cpp @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Tests.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" + +#include "utils/Logger.h" +#include "utils/Yaml.h" + +#include + +#include +#include + +extern void fixPermissions( const QDir& d ); + +QTEST_GUILESS_MAIN( InitcpioTests ) + +InitcpioTests::InitcpioTests() {} + +InitcpioTests::~InitcpioTests() {} + +void +InitcpioTests::initTestCase() +{ +} + +void +InitcpioTests::testFixPermissions() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "Fixing up /boot"; + fixPermissions( QDir( "/boot" ) ); + cDebug() << "Fixing up /nonexistent"; + fixPermissions( QDir( "/nonexistent/nonexistent" ) ); + QVERIFY( true ); +} diff --git a/calamares/src/modules/initcpio/Tests.h b/calamares/src/modules/initcpio/Tests.h new file mode 100644 index 0000000..aac48d0 --- /dev/null +++ b/calamares/src/modules/initcpio/Tests.h @@ -0,0 +1,27 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TESTS_H +#define TESTS_H + +#include + +class InitcpioTests : public QObject +{ + Q_OBJECT +public: + InitcpioTests(); + ~InitcpioTests() override; + +private Q_SLOTS: + void initTestCase(); + void testFixPermissions(); +}; + +#endif diff --git a/calamares/src/modules/initcpio/initcpio.conf b/calamares/src/modules/initcpio/initcpio.conf new file mode 100644 index 0000000..d2a1268 --- /dev/null +++ b/calamares/src/modules/initcpio/initcpio.conf @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Run mkinitcpio(8) with the given preset value +--- +# This key defines the kernel to be loaded. +# It can have the following values: +# - the name of a single mkinitcpio preset +# - empty or unset +# - the literal string "all" +# +# If kernel is set to "all" or empty/unset then mkinitpio is called for all +# kernels. Otherwise it is called with a single preset with the value +# contained in kernel. +# +kernel: linux + +# Set this to true to turn off mitigations for lax file +# permissions on initramfs (which, in turn, can compromise +# your LUKS encryption keys, CVS-2019-13179). +# +# If your initramfs are stored in the EFI partition or another non-POSIX +# filesystem, this has no effect as the file permissions cannot be changed. +# In this case, ensure the partition is mounted securely. +# +be_unsafe: false diff --git a/calamares/src/modules/initcpio/initcpio.schema.yaml b/calamares/src/modules/initcpio/initcpio.schema.yaml new file mode 100644 index 0000000..2024182 --- /dev/null +++ b/calamares/src/modules/initcpio/initcpio.schema.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/initcpio +additionalProperties: false +type: object +properties: + kernel: { type: string } + be_unsafe: { type: boolean, default: false } +required: [ kernel ] diff --git a/calamares/src/modules/initcpiocfg/initcpiocfg.conf b/calamares/src/modules/initcpiocfg/initcpiocfg.conf new file mode 100644 index 0000000..a660393 --- /dev/null +++ b/calamares/src/modules/initcpiocfg/initcpiocfg.conf @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# The initcpiocfg module is responsible for the configuration of mkinitcpio.conf. Typically this +# module is used in conjunction with the initcpio module to generate the boot image when using mkinitcpio +--- +# +# Determines if the systemd versions of the hooks should be used. This is false by default. +# +# Please note that using the systemd hooks result in no access to the emergency recovery shell +useSystemdHook: false + +# +# Modifications to the standard list of hooks. +# +# There are three subkeys: +# - prepend, which puts hooks at the beginning of the +# list of hooks, in the order specified here, +# - append, which adds hooks at the end of the list of +# hooks, in the order specified here, +# - remove, which removes hooks from the list of hooks, +# wherever they may be. +# +# The example configuration here yields bogus, , bogus +# initially, and then removes that hook again. +# +hooks: + prepend: [ bogus ] + append: [ bogus ] + remove: [ bogus ] + +# +# In some cases, you may want to use a different source +# file than /etc/mkinitcpio.conf , e.g. because the live system +# does not match the target in a useful way. If unset or +# empty, defaults to /etc/mkinitcpio.conf +# +source: "/etc/mkinitcpio.conf" diff --git a/calamares/src/modules/initcpiocfg/initcpiocfg.schema.yaml b/calamares/src/modules/initcpiocfg/initcpiocfg.schema.yaml new file mode 100644 index 0000000..ddbe43b --- /dev/null +++ b/calamares/src/modules/initcpiocfg/initcpiocfg.schema.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2023 Evan James +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/initcpiocfg +additionalProperties: false +type: object +properties: + useSystemdHook: { type: boolean } + hooks: + type: object + additionalProperties: false + properties: + prepend: { type: array, items: { type: string } } + append: { type: array, items: { type: string } } + remove: { type: array, items: { type: string } } + source: { type: string } diff --git a/calamares/src/modules/initcpiocfg/main.py b/calamares/src/modules/initcpiocfg/main.py new file mode 100644 index 0000000..b6edc31 --- /dev/null +++ b/calamares/src/modules/initcpiocfg/main.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Rohan Garg +# SPDX-FileCopyrightText: 2015 2019-2020, Philip Müller +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# +import libcalamares +from libcalamares.utils import debug, target_env_call +import os +from collections import OrderedDict + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Configuring mkinitcpio.") + + +def detect_plymouth(): + """ + Checks existence (runnability) of plymouth in the target system. + + @return True if plymouth exists in the target, False otherwise + """ + # Used to only check existence of path /usr/bin/plymouth in target + return target_env_call(["sh", "-c", "which plymouth"]) == 0 + + +def get_host_initcpio(): + """ + Reads the host system mkinitcpio.conf and returns all + the lines from that file, or an empty list if it does + not exist. + """ + hostfile = libcalamares.job.configuration.get("source", None) or "/etc/mkinitcpio.conf" + try: + with open(hostfile, "r") as mkinitcpio_file: + mklins = [x.strip() for x in mkinitcpio_file.readlines()] + except FileNotFoundError: + libcalamares.utils.debug(f"Could not open host file {hostfile}") + mklins = [] + + return mklins + + +def write_mkinitcpio_lines(hooks, modules, files, binaries, root_mount_point): + """ + Set up mkinitcpio.conf. + + :param hooks: + :param modules: + :param files: + :param root_mount_point: + """ + mklins = get_host_initcpio() + + target_path = os.path.join(root_mount_point, "etc/mkinitcpio.conf") + with open(target_path, "w") as mkinitcpio_file: + for line in mklins: + # Replace HOOKS, MODULES, BINARIES and FILES lines with what we + # have found via find_initcpio_features() + if line.startswith("HOOKS"): + line = f"HOOKS=({str.join(' ', hooks)})" + elif line.startswith("BINARIES"): + line = f"BINARIES=({str.join(' ', binaries)})" + elif line.startswith("MODULES"): + line = f"MODULES=({str.join(' ', modules)})" + elif line.startswith("FILES"): + line = f"FILES=({str.join(' ', files)})" + mkinitcpio_file.write(line + "\n") + + +def find_initcpio_features(partitions, root_mount_point): + """ + Returns a tuple (hooks, modules, files) needed to support + the given @p partitions (filesystems types, encryption, etc) + in the target. + + :param partitions: (from GS) + :param root_mount_point: (from GS) + + :return 3-tuple of lists + """ + hooks = [ + "autodetect", + "microcode", + "kms", + "modconf", + "block", + "keyboard", + ] + + systemd_hook_allowed = libcalamares.job.configuration.get("useSystemdHook", False) + + use_systemd = systemd_hook_allowed and target_env_call(["sh", "-c", "which systemd-cat"]) == 0 + + if use_systemd: + hooks.insert(0, "systemd") + hooks.append("sd-vconsole") + else: + hooks.insert(0, "udev") + hooks.insert(0, "base") + hooks.append("keymap") + hooks.append("consolefont") + + hooks_map = libcalamares.job.configuration.get("hooks", None) + if not hooks_map: + hooks_map = dict() + hooks_prepend = hooks_map.get("prepend", None) or [] + hooks_append = hooks_map.get("append", None) or [] + hooks_remove = hooks_map.get("remove", None) or [] + + modules = [] + files = [] + binaries = [] + + swap_uuid = "" + uses_btrfs = False + uses_zfs = False + uses_lvm2 = False + encrypt_hook = False + openswap_hook = False + unencrypted_separate_boot = False + + # It is important that the plymouth hook comes before any encrypt hook + if detect_plymouth(): + hooks.append("plymouth") + + for partition in partitions: + if partition["fs"] == "linuxswap" and not partition.get("claimed", None): + # Skip foreign swap + continue + + if partition["fs"] == "linuxswap": + swap_uuid = partition["uuid"] + if "luksMapperName" in partition: + openswap_hook = True + + if partition["fs"] == "btrfs": + uses_btrfs = True + + # In addition to checking the filesystem, check to ensure that zfs is enabled + if partition["fs"] == "zfs" and libcalamares.globalstorage.contains("zfsPoolInfo"): + uses_zfs = True + + if "lvm2" in partition["fs"]: + uses_lvm2 = True + + if partition["mountPoint"] == "/" and "luksMapperName" in partition: + encrypt_hook = True + + if partition["mountPoint"] == "/boot" and "luksMapperName" not in partition: + unencrypted_separate_boot = True + + if partition["mountPoint"] == "/usr": + hooks.append("usr") + + if encrypt_hook: + if use_systemd: + hooks.append("sd-encrypt") + else: + hooks.append("encrypt") + crypto_file = "crypto_keyfile.bin" + if not unencrypted_separate_boot and os.path.isfile(os.path.join(root_mount_point, crypto_file)): + files.append(f"/{crypto_file}") + + if uses_lvm2: + hooks.append("lvm2") + + if uses_zfs: + hooks.append("zfs") + + if swap_uuid != "": + if encrypt_hook and openswap_hook: + hooks.extend(["openswap"]) + hooks.extend(["resume", "filesystems"]) + else: + hooks.extend(["filesystems"]) + + if not uses_btrfs: + hooks.append("fsck") + + # Modify according to the keys in the configuration + hooks = [h for h in (hooks_prepend + hooks + hooks_append) if h not in hooks_remove] + + return hooks, modules, files, binaries + + +def run(): + """ + Calls routine with given parameters to modify "/etc/mkinitcpio.conf". + + :return: + """ + partitions = libcalamares.globalstorage.value("partitions") + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + + if not partitions: + libcalamares.utils.warning(f"partitions are empty, {partitions}") + return (_("Configuration Error"), + _("No partitions are defined for
initcpiocfg
.")) + if not root_mount_point: + libcalamares.utils.warning(f"rootMountPoint is empty, {root_mount_point}") + return (_("Configuration Error"), + _("No root mount point for
initcpiocfg
.")) + + hooks, modules, files, binaries = find_initcpio_features(partitions, root_mount_point) + write_mkinitcpio_lines(hooks, modules, files, binaries, root_mount_point) + + return None diff --git a/calamares/src/modules/initcpiocfg/module.desc b/calamares/src/modules/initcpiocfg/module.desc new file mode 100644 index 0000000..9d7bfdf --- /dev/null +++ b/calamares/src/modules/initcpiocfg/module.desc @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Writes a mkinitcpio.conf into the target system. It copies +# the host system's /etc/mkinitcpio.conf, and replaces any +# HOOKS, MODULES, and FILES lines with calculated values +# based on what the installation (seems to) need. +--- +type: "job" +name: "initcpiocfg" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/initcpiocfg/test.yaml b/calamares/src/modules/initcpiocfg/test.yaml new file mode 100644 index 0000000..f832bec --- /dev/null +++ b/calamares/src/modules/initcpiocfg/test.yaml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +rootMountPoint: /tmp/mount +partitions: + - fs: ext4 + mountPoint: "/" + diff --git a/calamares/src/modules/initramfs/CMakeLists.txt b/calamares/src/modules/initramfs/CMakeLists.txt new file mode 100644 index 0000000..45f29c3 --- /dev/null +++ b/calamares/src/modules/initramfs/CMakeLists.txt @@ -0,0 +1,20 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(initramfs + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + InitramfsJob.cpp + SHARED_LIB +) + +calamares_add_test( + initramfstest + SOURCES Tests.cpp + LIBRARIES + calamares_job_initramfs # From above + yamlcpp::yamlcpp +) diff --git a/calamares/src/modules/initramfs/InitramfsJob.cpp b/calamares/src/modules/initramfs/InitramfsJob.cpp new file mode 100644 index 0000000..f61679f --- /dev/null +++ b/calamares/src/modules/initramfs/InitramfsJob.cpp @@ -0,0 +1,90 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "InitramfsJob.h" + +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/UMask.h" +#include "utils/Variant.h" + +InitramfsJob::InitramfsJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +InitramfsJob::~InitramfsJob() {} + +QString +InitramfsJob::prettyName() const +{ + return tr( "Creating initramfs…", "@status" ); +} + +Calamares::JobResult +InitramfsJob::exec() +{ + Calamares::UMask m( Calamares::UMask::Safe ); + + cDebug() << "Updating initramfs with kernel" << m_kernel; + + if ( m_unsafe ) + { + cDebug() << "Skipping mitigations for unsafe initramfs permissions."; + } + else + { + // First make sure we generate a safe initramfs with suitable permissions. + static const char confFile[] = "/etc/initramfs-tools/conf.d/calamares-safe-initramfs.conf"; + static const char contents[] = "UMASK=0077\n"; + if ( Calamares::System::instance()->createTargetFile( confFile, QByteArray( contents ) ).failed() ) + { + cWarning() << Logger::SubEntry << "Could not configure safe UMASK for initramfs."; + // But continue anyway. + } + } + + // And then do the ACTUAL work. + auto r = Calamares::System::instance()->targetEnvCommand( + { "update-initramfs", "-k", m_kernel, "-c", "-t" }, QString(), QString() /* no timeout, 0 */ ); + return r.explainProcess( "update-initramfs", std::chrono::seconds( 10 ) /* fake timeout */ ); +} + +void +InitramfsJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_kernel = Calamares::getString( configurationMap, "kernel" ); + if ( m_kernel.isEmpty() ) + { + m_kernel = QStringLiteral( "all" ); + } + else if ( m_kernel == "$uname" ) + { + auto r = Calamares::System::runCommand( Calamares::System::RunLocation::RunInHost, + { "/bin/uname", "-r" }, + QString(), + QString(), + std::chrono::seconds( 3 ) ); + if ( r.getExitCode() == 0 ) + { + m_kernel = r.getOutput(); + cDebug() << "*initramfs* using running kernel" << m_kernel; + } + else + { + m_kernel = QStringLiteral( "all" ); + cWarning() << "*initramfs* could not determine running kernel, using 'all'." << Logger::Continuation + << r.getExitCode() << r.getOutput(); + } + } + + m_unsafe = Calamares::getBool( configurationMap, "be_unsafe", false ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( InitramfsJobFactory, registerPlugin< InitramfsJob >(); ) diff --git a/calamares/src/modules/initramfs/InitramfsJob.h b/calamares/src/modules/initramfs/InitramfsJob.h new file mode 100644 index 0000000..c09c9eb --- /dev/null +++ b/calamares/src/modules/initramfs/InitramfsJob.h @@ -0,0 +1,41 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef INITRAMFSJOB_H +#define INITRAMFSJOB_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include + +class PLUGINDLLEXPORT InitramfsJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit InitramfsJob( QObject* parent = nullptr ); + ~InitramfsJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_kernel; + bool m_unsafe = false; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( InitramfsJobFactory ) + +#endif // INITRAMFSJOB_H diff --git a/calamares/src/modules/initramfs/Tests.cpp b/calamares/src/modules/initramfs/Tests.cpp new file mode 100644 index 0000000..3dd7788 --- /dev/null +++ b/calamares/src/modules/initramfs/Tests.cpp @@ -0,0 +1,74 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Tests.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" + +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include + +#include +#include + +QTEST_GUILESS_MAIN( InitramfsTests ) + +InitramfsTests::InitramfsTests() {} + +InitramfsTests::~InitramfsTests() {} + +void +InitramfsTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + (void)new Calamares::JobQueue(); + (void)new Calamares::System( true ); +} + +static const char contents[] = "UMASK=0077\n"; +static const char confFile[] = "/tmp/calamares-safe-umask"; + +void +InitramfsTests::cleanup() +{ + QFile::remove( confFile ); +} + +void +InitramfsTests::testCreateTargetFile() +{ + static const char short_confFile[] = "/calamares-safe-umask"; + + auto* s = Calamares::System::instance(); + auto r = s->createTargetFile( short_confFile, QByteArray( contents ) ); + QVERIFY( r.failed() ); + QVERIFY( !r ); + QString path = r.path(); + QVERIFY( path.isEmpty() ); // because no rootmountpoint is set + + Calamares::JobQueue::instance()->globalStorage()->insert( "rootMountPoint", "/tmp" ); + + path = s->createTargetFile( short_confFile, QByteArray( contents ) ).path(); + QCOMPARE( path, QString( confFile ) ); + QVERIFY( path.endsWith( short_confFile ) ); // chroot, so path create relative to + QVERIFY( path.startsWith( "/tmp/" ) ); + QVERIFY( QFile::exists( path ) ); + + QFileInfo fi( path ); + QVERIFY( fi.exists() ); + QCOMPARE( ulong( fi.size() ), sizeof( contents ) - 1 ); // don't count trailing NUL + + QFile::remove( path ); +} diff --git a/calamares/src/modules/initramfs/Tests.h b/calamares/src/modules/initramfs/Tests.h new file mode 100644 index 0000000..3774245 --- /dev/null +++ b/calamares/src/modules/initramfs/Tests.h @@ -0,0 +1,30 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TESTS_H +#define TESTS_H + +#include + +class InitramfsTests : public QObject +{ + Q_OBJECT +public: + InitramfsTests(); + ~InitramfsTests() override; + +private Q_SLOTS: + void initTestCase(); + void cleanup(); + + // TODO: this doesn't actually test any of the functionality of this job + void testCreateTargetFile(); +}; + +#endif diff --git a/calamares/src/modules/initramfs/initramfs.conf b/calamares/src/modules/initramfs/initramfs.conf new file mode 100644 index 0000000..c9dcf16 --- /dev/null +++ b/calamares/src/modules/initramfs/initramfs.conf @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +## initramfs module +# +# This module is specific to Debian based distros. Post installation on Debian +# the initramfs needs to be updated so as to not interrupt the boot process +# with a error about fsck.ext4 not being found. +# +## Debian specific notes +# +# If you're using live-build to build your ISO and setup the runtime env +# make sure that you purge the live-\* packages on the target system +# before running this module, since live-config dpkg-diverts update-initramfs +# and can cause all sorts of fun issues. +--- +# There is only one configuration item for this module, +# the kernel to be loaded. This can have the following +# values: +# - empty or unset, interpreted as "all" +# - the literal string "$uname" (without quotes, with dollar), +# which will use the output of `uname -r` to determine the +# running kernel, and use that. +# - any other string. +# +# Whatever is set, that string is passed as *version* argument to the +# `-k` option of *update-initramfs*. Take care that both "$uname" operates +# in the host system, and might not be correct if the target system is +# updated (to a newer kernel) as part of the installation. +# +# The default is empty/unset, leading to the behavior from Calamares +# 3.2.9 and earlier which passed "all" as version. + +kernel: "all" + +# Set this to true to turn off mitigations for lax file +# permissions on initramfs (which, in turn, can compromise +# your LUKS encryption keys, CVS-2019-13179). +be_unsafe: false diff --git a/calamares/src/modules/initramfscfg/encrypt_hook b/calamares/src/modules/initramfscfg/encrypt_hook new file mode 100755 index 0000000..70d661a --- /dev/null +++ b/calamares/src/modules/initramfscfg/encrypt_hook @@ -0,0 +1,29 @@ +#!/bin/sh +# +# SPDX-FileCopyrightText: 2016 David McKinney +# SPDX-License-Identifier: GPL-3.0-or-later + + PREREQ="" + + prereqs() + { + echo "$PREREQ" + } + + case $1 in + # get pre-requisites + prereqs) + prereqs + exit 0 + ;; + esac + + . /usr/share/initramfs-tools/hook-functions + if [ -f /crypto_keyfile.bin ] + then + cp /crypto_keyfile.bin ${DESTDIR} + fi + if [ -f /etc/crypttab ] + then + cp /etc/crypttab ${DESTDIR}/etc/ + fi diff --git a/calamares/src/modules/initramfscfg/encrypt_hook_nokey b/calamares/src/modules/initramfscfg/encrypt_hook_nokey new file mode 100755 index 0000000..8ee669c --- /dev/null +++ b/calamares/src/modules/initramfscfg/encrypt_hook_nokey @@ -0,0 +1,25 @@ +#!/bin/sh +# +# SPDX-FileCopyrightText: 2016 David McKinney +# SPDX-License-Identifier: GPL-3.0-or-later + + PREREQ="" + + prereqs() + { + echo "$PREREQ" + } + + case $1 in + # get pre-requisites + prereqs) + prereqs + exit 0 + ;; + esac + + . /usr/share/initramfs-tools/hook-functions + if [ -f /etc/crypttab ] + then + cp /etc/crypttab ${DESTDIR}/etc/ + fi diff --git a/calamares/src/modules/initramfscfg/main.py b/calamares/src/modules/initramfscfg/main.py new file mode 100644 index 0000000..974e263 --- /dev/null +++ b/calamares/src/modules/initramfscfg/main.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Rohan Garg +# SPDX-FileCopyrightText: 2015 Philip Müller +# SPDX-FileCopyrightText: 2016 David McKinney +# SPDX-FileCopyrightText: 2016 Kevin Kofler +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import libcalamares + +import inspect +import os +import shutil + + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Configuring initramfs.") + + +def copy_initramfs_hooks(partitions, root_mount_point): + """ + Copies initramfs hooks so they are picked up by update-initramfs + + :param partitions: + :param root_mount_point: + """ + encrypt_hook = False + unencrypted_separate_boot = False + + for partition in partitions: + if partition["mountPoint"] == "/" and "luksMapperName" in partition: + encrypt_hook = True + + if (partition["mountPoint"] == "/boot" + and "luksMapperName" not in partition): + unencrypted_separate_boot = True + + if encrypt_hook: + target = "{!s}/usr/share/initramfs-tools/hooks/encrypt_hook".format( + root_mount_point) + + # Find where this module is installed + _filename = inspect.getframeinfo(inspect.currentframe()).filename + _path = os.path.dirname(os.path.abspath(_filename)) + + if unencrypted_separate_boot: + shutil.copy2( + os.path.join(_path, "encrypt_hook_nokey"), + target + ) + else: + shutil.copy2( + os.path.join(_path, "encrypt_hook"), + target + ) + os.chmod(target, 0o755) + + +def run(): + """ + Calls routine with given parameters to configure initramfs + + :return: + """ + partitions = libcalamares.globalstorage.value("partitions") + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + + if not partitions: + libcalamares.utils.warning("partitions is empty, {!s}".format(partitions)) + return (_("Configuration Error"), + _("No partitions are defined for
{!s}
to use." ).format("initramfscfg")) + if not root_mount_point: + libcalamares.utils.warning("rootMountPoint is empty, {!s}".format(root_mount_point)) + return (_("Configuration Error"), + _("No root mount point is given for
{!s}
to use." ).format("initramfscfg")) + + copy_initramfs_hooks(partitions, root_mount_point) + + return None diff --git a/calamares/src/modules/initramfscfg/module.desc b/calamares/src/modules/initramfscfg/module.desc new file mode 100644 index 0000000..17db294 --- /dev/null +++ b/calamares/src/modules/initramfscfg/module.desc @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "initramfscfg" +interface: "python" +script: "main.py" +noconfig: true diff --git a/calamares/src/modules/interactiveterminal/CMakeLists.txt b/calamares/src/modules/interactiveterminal/CMakeLists.txt new file mode 100644 index 0000000..6d153cc --- /dev/null +++ b/calamares/src/modules/interactiveterminal/CMakeLists.txt @@ -0,0 +1,25 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +find_package(${kfname}Service ${KF_VERSION}) +find_package(${kfname}Parts ${KF_VERSION}) +set_package_properties(${kfname}Service PROPERTIES PURPOSE "For finding KDE services at runtime") +set_package_properties(${kfname}Parts PROPERTIES PURPOSE "For finding KDE parts at runtime") + +if(${kfname}Parts_FOUND AND ${kfname}Service_FOUND) + calamares_add_plugin(interactiveterminal + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + InteractiveTerminalViewStep.cpp + InteractiveTerminalPage.cpp + LINK_LIBRARIES + ${kfname}::Service + ${kfname}::Parts + SHARED_LIB + ) +else() + calamares_skip_module( "interactiveterminal (missing requirements)" ) +endif() diff --git a/calamares/src/modules/interactiveterminal/InteractiveTerminalPage.cpp b/calamares/src/modules/interactiveterminal/InteractiveTerminalPage.cpp new file mode 100644 index 0000000..fb31f26 --- /dev/null +++ b/calamares/src/modules/interactiveterminal/InteractiveTerminalPage.cpp @@ -0,0 +1,128 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2024 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "InteractiveTerminalPage.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "viewpages/ViewStep.h" +#include "widgets/TranslationFix.h" + +#include +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) +#include +#include +#else +#include +#include +#endif +#include + +#include +#include +#include +#include +#include + +InteractiveTerminalPage::InteractiveTerminalPage( QWidget* parent ) + : QWidget( parent ) + , m_layout( new QVBoxLayout( this ) ) + , m_termHostWidget( nullptr ) +{ + setLayout( m_layout ); + m_layout->setContentsMargins( 0, 0, 0, 0 ); + + m_headerLabel = new QLabel( this ); + m_layout->addWidget( m_headerLabel ); +} + +void +InteractiveTerminalPage::errorKonsoleNotInstalled() +{ + QMessageBox mb( QMessageBox::Critical, + tr( "Konsole not installed.", "@error" ), + tr( "Please install KDE Konsole and try again!", "@info" ), + QMessageBox::Ok ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); +} + +void +InteractiveTerminalPage::onActivate() +{ + if ( m_termHostWidget ) + { + return; + } + +#if KCOREADDONS_VERSION_MAJOR > 5 || KCOREADDONS_VERSION_MINOR > 200 + auto md = KPluginMetaData::findPluginById( QString(), "kf6/parts/konsolepart" ); + if ( !md.isValid() ) + { + errorKonsoleNotInstalled(); + return; + } + auto* p = KPluginFactory::instantiatePlugin< KParts::ReadOnlyPart >( md, this ).plugin; +#elif KCOREADDONS_VERSION_MINOR >= 86 + // 5.86 deprecated a bunch of KService and PluginFactory and related methods + auto md = KPluginMetaData::findPluginById( QString(), "konsolepart" ); + if ( !md.isValid() ) + { + errorKonsoleNotInstalled(); + return; + } + auto* p = KPluginFactory::instantiatePlugin< KParts::ReadOnlyPart >( md, this ).plugin; +#else + KService::Ptr service = KService::serviceByDesktopName( "konsolepart" ); + if ( !service ) + { + errorKonsoleNotInstalled(); + return; + } + + // Create one instance of konsolepart. + KParts::ReadOnlyPart* p = service->createInstance< KParts::ReadOnlyPart >( this, this, {} ); +#endif + if ( !p ) + { + // One more opportunity for the loading operation to fail. + errorKonsoleNotInstalled(); + return; + } + + // Cast the konsolepart to the TerminalInterface... + TerminalInterface* t = qobject_cast< TerminalInterface* >( p ); + if ( !t ) + { + // This is why we can't have nice things. + errorKonsoleNotInstalled(); + return; + } + + // Make the widget persist even if the KPart goes out of scope... + p->setAutoDeleteWidget( false ); + // ... but kill the KPart if the widget goes out of scope. + p->setAutoDeletePart( true ); + + m_termHostWidget = p->widget(); + m_layout->addWidget( m_termHostWidget ); + + t->showShellInDir( QDir::home().path() ); + t->sendInput( QString( "%1\n" ).arg( m_command ) ); +} + +void +InteractiveTerminalPage::setCommand( const QString& command ) +{ + m_command = command; + CALAMARES_RETRANSLATE( + m_headerLabel->setText( tr( "Executing script:  %1", "@info" ).arg( m_command ) ); ); +} diff --git a/calamares/src/modules/interactiveterminal/InteractiveTerminalPage.h b/calamares/src/modules/interactiveterminal/InteractiveTerminalPage.h new file mode 100644 index 0000000..86ba075 --- /dev/null +++ b/calamares/src/modules/interactiveterminal/InteractiveTerminalPage.h @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef INTERACTIVETERMINALPAGE_H +#define INTERACTIVETERMINALPAGE_H + +#include + +class QLabel; +class QVBoxLayout; + +class InteractiveTerminalPage : public QWidget +{ + Q_OBJECT +public: + explicit InteractiveTerminalPage( QWidget* parent = nullptr ); + + void onActivate(); + + void setCommand( const QString& command ); + +private: + QVBoxLayout* m_layout; + QWidget* m_termHostWidget; + QString m_command; + QLabel* m_headerLabel; + + void errorKonsoleNotInstalled(); +}; + +#endif // INTERACTIVETERMINALPAGE_H diff --git a/calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.cpp b/calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.cpp new file mode 100644 index 0000000..6f0457f --- /dev/null +++ b/calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.cpp @@ -0,0 +1,95 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "InteractiveTerminalViewStep.h" + +#include "InteractiveTerminalPage.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" + +#include + +CALAMARES_PLUGIN_FACTORY_DEFINITION( InteractiveTerminalViewStepFactory, + registerPlugin< InteractiveTerminalViewStep >(); ) + +InteractiveTerminalViewStep::InteractiveTerminalViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_widget( new InteractiveTerminalPage() ) +{ + emit nextStatusChanged( true ); +} + +InteractiveTerminalViewStep::~InteractiveTerminalViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + +QString +InteractiveTerminalViewStep::prettyName() const +{ + return tr( "Script", "@label" ); +} + +QWidget* +InteractiveTerminalViewStep::widget() +{ + return m_widget; +} + +bool +InteractiveTerminalViewStep::isNextEnabled() const +{ + return true; +} + +bool +InteractiveTerminalViewStep::isBackEnabled() const +{ + return true; +} + +bool +InteractiveTerminalViewStep::isAtBeginning() const +{ + return true; +} + +bool +InteractiveTerminalViewStep::isAtEnd() const +{ + return true; +} + +QList< Calamares::job_ptr > +InteractiveTerminalViewStep::jobs() const +{ + cDebug() << "InteractiveTerminal" << prettyName() << "asked for jobs(), this is probably wrong."; + return QList< Calamares::job_ptr >(); +} + +void +InteractiveTerminalViewStep::onActivate() +{ + cDebug() << "InteractiveTerminal" << prettyName() << "activated."; + m_widget->onActivate(); +} + +void +InteractiveTerminalViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + if ( configurationMap.contains( "command" ) + && Calamares::typeOf( configurationMap.value( "command" ) ) == Calamares::StringVariantType ) + { + m_widget->setCommand( configurationMap.value( "command" ).toString() ); + } +} diff --git a/calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.h b/calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.h new file mode 100644 index 0000000..8e0e650 --- /dev/null +++ b/calamares/src/modules/interactiveterminal/InteractiveTerminalViewStep.h @@ -0,0 +1,54 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef INTERACTIVETERMINALPAGEPLUGIN_H +#define INTERACTIVETERMINALPAGEPLUGIN_H + +#include + +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include "DllMacro.h" + +class InteractiveTerminalPage; + +class PLUGINDLLEXPORT InteractiveTerminalViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit InteractiveTerminalViewStep( QObject* parent = nullptr ); + ~InteractiveTerminalViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + QList< Calamares::job_ptr > jobs() const override; + + void onActivate() override; + +protected: + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + InteractiveTerminalPage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( InteractiveTerminalViewStepFactory ) + +#endif // INTERACTIVETERMINALPAGEPLUGIN_H diff --git a/calamares/src/modules/interactiveterminal/interactiveterminal.conf b/calamares/src/modules/interactiveterminal/interactiveterminal.conf new file mode 100644 index 0000000..9354f8f --- /dev/null +++ b/calamares/src/modules/interactiveterminal/interactiveterminal.conf @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# The interactive terminal provides a konsole (terminal) window +# during the installation process. The terminal runs in the +# host system, so you will need to change directories to the +# target system to examine the state there. +# +# The one configuration key *command*, if defined, is passed +# as a command to run in the terminal window before any user +# input is accepted. The user must exit the terminal manually +# or click *next* to proceed to the next installation step. +# +# If no command is defined, no command is run and the user +# gets a plain terminal session. +--- +command: "echo Hello" diff --git a/calamares/src/modules/keyboard/AdditionalLayoutInfo.h b/calamares/src/modules/keyboard/AdditionalLayoutInfo.h new file mode 100644 index 0000000..f606bfe --- /dev/null +++ b/calamares/src/modules/keyboard/AdditionalLayoutInfo.h @@ -0,0 +1,33 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Artem Grinev + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARD_ADDITIONAL_LAYOUT_INFO_H +#define KEYBOARD_ADDITIONAL_LAYOUT_INFO_H + +#include + +struct BasicLayoutInfo +{ + QString selectedLayout; + QString selectedModel; + QString selectedVariant; + QString selectedGroup; +}; + +struct AdditionalLayoutInfo +{ + QString additionalLayout; + QString additionalVariant; + + QString groupSwitcher; + + QString vconsoleKeymap; +}; + +#endif diff --git a/calamares/src/modules/keyboard/CMakeLists.txt b/calamares/src/modules/keyboard/CMakeLists.txt new file mode 100644 index 0000000..3928424 --- /dev/null +++ b/calamares/src/modules/keyboard/CMakeLists.txt @@ -0,0 +1,27 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +calamares_add_plugin(keyboard + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + KeyboardViewStep.cpp + KeyboardPage.cpp + KeyboardLayoutModel.cpp + SetKeyboardLayoutJob.cpp + keyboardwidget/keyboardglobal.cpp + keyboardwidget/keyboardpreview.cpp + UI + KeyboardPage.ui + RESOURCES + keyboard.qrc + SHARED_LIB + LINK_LIBRARIES + ${qtname}::DBus +) + +calamares_add_test(keyboardtest SOURCES Tests.cpp SetKeyboardLayoutJob.cpp RESOURCES keyboard.qrc) diff --git a/calamares/src/modules/keyboard/Config.cpp b/calamares/src/modules/keyboard/Config.cpp new file mode 100644 index 0000000..a7c8160 --- /dev/null +++ b/calamares/src/modules/keyboard/Config.cpp @@ -0,0 +1,917 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 Camilo Higuita * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "SetKeyboardLayoutJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "locale/Global.h" +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/Retranslator.h" +#include "utils/String.h" +#include "utils/Variant.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +/* Returns stringlist with suitable setxkbmap command-line arguments + * to set the given @p model. + */ +static QStringList +xkbmap_model_args( const QString& model ) +{ + QStringList r { "-model", model }; + return r; +} + +/* Returns stringlist with suitable setxkbmap command-line arguments + * to set the given @p layout and @p variant. + */ +static QStringList +xkbmap_layout_args( const QString& layout, const QString& variant ) +{ + QStringList r { "-layout", layout }; + if ( !variant.isEmpty() ) + { + r << "-variant" << variant; + } + return r; +} + +static QStringList +xkbmap_layout_args_with_group_switch( const QStringList& layouts, + const QStringList& variants, + const QString& switchOption ) +{ + if ( layouts.size() != variants.size() ) + { + cError() << "Number of layouts and variants must be equal (empty string should be used if there is no " + "corresponding variant)"; + return QStringList(); + } + + QStringList r { "-layout", layouts.join( "," ) }; + + if ( !variants.isEmpty() ) + { + r << "-variant" << variants.join( "," ); + } + + if ( !switchOption.isEmpty() ) + { + r << "-option" << switchOption; + } + + return r; +} + +/* Returns group-switch setxkbd option if set + * or an empty string otherwise + */ +static inline QString +xkbmap_query_grp_option() +{ + QProcess setxkbmapQuery; + setxkbmapQuery.start( "setxkbmap", { "-query" } ); + setxkbmapQuery.waitForFinished(); + + QString outputLine; + + do + { + outputLine = setxkbmapQuery.readLine(); + } while ( setxkbmapQuery.canReadLine() && !outputLine.startsWith( "options:" ) ); + + if ( !outputLine.startsWith( "options:" ) ) + { + return QString(); + } + + int index = outputLine.indexOf( "grp:" ); + + if ( index == -1 ) + { + return QString(); + } + + //it's either in the end of line or before the other option so \s or , + int lastIndex = outputLine.indexOf( QRegularExpression( "[\\s,]" ), index ); + + return outputLine.mid( index, lastIndex - index ); +} + +AdditionalLayoutInfo +Config::getAdditionalLayoutInfo( const QString& layout ) +{ + QFile layoutTable( ":/non-ascii-layouts" ); + + if ( !layoutTable.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + cError() << "Non-ASCII layout table could not be opened"; + return AdditionalLayoutInfo(); + } + + QString tableLine; + + do + { + tableLine = layoutTable.readLine(); + } while ( layoutTable.canReadLine() && !tableLine.startsWith( layout ) ); + + if ( !tableLine.startsWith( layout ) ) + { + return AdditionalLayoutInfo(); + } + + QStringList tableEntries = tableLine.split( " ", SplitSkipEmptyParts ); + + AdditionalLayoutInfo r; + + r.additionalLayout = tableEntries[ 1 ]; + r.additionalVariant = tableEntries[ 2 ] == "-" ? "" : tableEntries[ 2 ]; + + r.vconsoleKeymap = tableEntries[ 3 ]; + + return r; +} + +Config::Config( QObject* parent ) + : QObject( parent ) + , m_keyboardModelsModel( new KeyboardModelsModel( this ) ) + , m_keyboardLayoutsModel( new KeyboardLayoutModel( this ) ) + , m_keyboardVariantsModel( new KeyboardVariantsModel( this ) ) + , m_KeyboardGroupSwitcherModel( new KeyboardGroupsSwitchersModel( this ) ) +{ + m_applyTimer.setSingleShot( true ); + connect( &m_applyTimer, &QTimer::timeout, this, &Config::apply ); + + // Connect signals and slots + connect( m_keyboardModelsModel, + &KeyboardModelsModel::currentIndexChanged, + [ & ]( int index ) + { + m_current.selectedModel = m_keyboardModelsModel->key( index ); + somethingChanged(); + } ); + + connect( m_keyboardLayoutsModel, + &KeyboardLayoutModel::currentIndexChanged, + [ & ]( int index ) + { + m_current.selectedLayout = m_keyboardLayoutsModel->item( index ).first; + updateVariants( QPersistentModelIndex( m_keyboardLayoutsModel->index( index ) ) ); + emit prettyStatusChanged(); + } ); + + connect( m_keyboardVariantsModel, + &KeyboardVariantsModel::currentIndexChanged, + [ & ]( int index ) + { + m_current.selectedVariant = m_keyboardVariantsModel->key( index ); + somethingChanged(); + } ); + connect( m_KeyboardGroupSwitcherModel, + &KeyboardGroupsSwitchersModel::currentIndexChanged, + [ & ]( int index ) + { + m_current.selectedGroup = m_KeyboardGroupSwitcherModel->key( index ); + somethingChanged(); + } ); + + // If the user picks something explicitly -- not a consequence of + // a guess -- then move to UserSelected state and stay there. + connect( m_keyboardModelsModel, &KeyboardModelsModel::currentIndexChanged, this, &Config::selectionChange ); + connect( m_keyboardLayoutsModel, &KeyboardLayoutModel::currentIndexChanged, this, &Config::selectionChange ); + connect( m_keyboardVariantsModel, &KeyboardVariantsModel::currentIndexChanged, this, &Config::selectionChange ); + connect( m_KeyboardGroupSwitcherModel, + &KeyboardGroupsSwitchersModel::currentIndexChanged, + this, + &Config::selectionChange ); + + m_current.selectedModel = m_keyboardModelsModel->key( m_keyboardModelsModel->currentIndex() ); + m_current.selectedLayout = m_keyboardLayoutsModel->item( m_keyboardLayoutsModel->currentIndex() ).first; + m_current.selectedVariant = m_keyboardVariantsModel->key( m_keyboardVariantsModel->currentIndex() ); + m_current.selectedGroup = m_KeyboardGroupSwitcherModel->key( m_KeyboardGroupSwitcherModel->currentIndex() ); +} + +void +Config::somethingChanged() +{ + if ( m_applyTimer.isActive() ) + { + m_applyTimer.stop(); + } + m_applyTimer.start( QApplication::keyboardInputInterval() ); + emit prettyStatusChanged(); +} + +static void +applyXkb( const BasicLayoutInfo& settings, AdditionalLayoutInfo& extra ) +{ + QStringList basicArguments = xkbmap_model_args( settings.selectedModel ); + if ( !extra.additionalLayout.isEmpty() ) + { + if ( !settings.selectedGroup.isEmpty() ) + { + extra.groupSwitcher = "grp:" + settings.selectedGroup; + } + + if ( extra.groupSwitcher.isEmpty() ) + { + extra.groupSwitcher = xkbmap_query_grp_option(); + } + if ( extra.groupSwitcher.isEmpty() ) + { + extra.groupSwitcher = "grp:alt_shift_toggle"; + } + + basicArguments.append( + xkbmap_layout_args_with_group_switch( { extra.additionalLayout, settings.selectedLayout }, + { extra.additionalVariant, settings.selectedVariant }, + extra.groupSwitcher ) ); + QProcess::execute( "setxkbmap", basicArguments ); + + cDebug() << "xkbmap selection changed to: " << settings.selectedLayout << '-' << settings.selectedVariant + << "(added " << extra.additionalLayout << "-" << extra.additionalVariant + << " since current layout is not ASCII-capable)"; + } + else + { + basicArguments.append( xkbmap_layout_args( settings.selectedLayout, settings.selectedVariant ) ); + QProcess::execute( "setxkbmap", basicArguments ); + cDebug() << "xkbmap selection changed to: " << settings.selectedLayout << '-' << settings.selectedVariant; + } +} + +static void +applyLocale1( const BasicLayoutInfo& settings, AdditionalLayoutInfo& extra ) +{ + QString layout = settings.selectedLayout; + QString variant = settings.selectedVariant; + QString option; + + if ( !extra.additionalLayout.isEmpty() ) + { + layout = extra.additionalLayout + "," + layout; + variant = extra.additionalVariant + "," + variant; + option = extra.groupSwitcher; + } + + QDBusInterface locale1( "org.freedesktop.locale1", + "/org/freedesktop/locale1", + "org.freedesktop.locale1", + QDBusConnection::systemBus() ); + if ( !locale1.isValid() ) + { + cWarning() << "Interface" << locale1.interface() << "is not valid."; + return; + } + + // Using convert=true, this also updates the VConsole config + { + QDBusReply< void > r + = locale1.call( "SetX11Keyboard", layout, settings.selectedModel, variant, option, true, false ); + if ( !r.isValid() ) + { + cWarning() << "Could not set keyboard config through org.freedesktop.locale1.X11Keyboard." << r.error(); + } + } +} + +// In a config-file's list of lines, replace lines = by = +static void +replaceKey( QStringList& content, const QString& key, const QString& value ) +{ + for ( int i = 0; i < content.length(); ++i ) + { + if ( content.at( i ).startsWith( key ) ) + { + content[ i ] = key + value; + } + } +} + +static bool +rewriteKWin( const QString& path, const QString& model, const QString& layouts, const QString& variants ) +{ + if ( !QFile::exists( path ) ) + { + return false; + } + + QFile config( path ); + if ( !config.open( QIODevice::ReadOnly ) ) + { + return false; + } + QStringList content = []( QFile& f ) + { + QTextStream s( &f ); + return s.readAll().split( '\n' ); + }( config ); + config.close(); + + if ( !config.open( QIODevice::WriteOnly ) ) + { + return false; + } + + replaceKey( content, QStringLiteral( "Model=" ), model ); + replaceKey( content, QStringLiteral( "LayoutList=" ), layouts ); + replaceKey( content, QStringLiteral( "VariantList=" ), variants ); + + config.write( content.join( '\n' ).toUtf8() ); + config.close(); + + return true; +} + +void +applyKWin( const BasicLayoutInfo& settings, AdditionalLayoutInfo& extra ) +{ + const auto paths = QStandardPaths::standardLocations( QStandardPaths::ConfigLocation ); + + auto join = [ &additional = extra.additionalLayout ]( const QString& s1, const QString& s2 ) + { return additional.isEmpty() ? s1 : QStringLiteral( "%1,%2" ).arg( s1, s2 ); }; + + const QString layouts = join( settings.selectedLayout, extra.additionalLayout ); + const QString variants = join( settings.selectedVariant, extra.additionalVariant ); + + bool updated = false; + for ( const auto& path : paths ) + { + const QString candidate = path + QStringLiteral( "/kxkbrc" ); + if ( rewriteKWin( candidate, settings.selectedModel, layouts, variants ) ) + { + updated = true; + break; + } + } + + if ( updated ) + { + auto kwin = QDBusMessage::createSignal( + QStringLiteral( "/Layouts" ), QStringLiteral( "org.kde.keyboard" ), QStringLiteral( "reloadConfig" ) ); + QDBusConnection::sessionBus().send( kwin ); + } +} + +QString +squareBracketedList( const QStringList& l ) +{ + return QStringLiteral( "[%1]" ).arg( l.join( ", " ) ); +} + +// Fpr a layout and variant, returns a string like "('xkb', 'uk+latin1')" +QString +concatLayoutAndVariant( const QString& layout, const QString& variant ) +{ + return QStringLiteral( "('xkb', '%1')" ).arg( variant.isEmpty() ? layout : ( layout + '+' + variant ) ); +} + +// Seem's keyboard settings don't work anymore with setxkbkeyboard with Gnome and Wayland +// use applyGnome() to use gsettings specific command +void +applyGnome( const BasicLayoutInfo& settings, AdditionalLayoutInfo& extra ) +{ + static constexpr int expectedUID = 1000; // Assume this is the live-cd user-id + const QString sudoUser + = QStringLiteral( "#%1" ).arg( expectedUID ); // GNU sudo can use '-u #nnn' with a literal '#' and numeric UID + const QString dbusPath = QStringLiteral( "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%1/bus" ).arg( expectedUID ); + const QString sudo = QStringLiteral( "sudo" ); + // clang-format off + // These are arguments to sudo to run gsettings to set something on input-sources + const QStringList sudoArguments{ + "-u", sudoUser, // Run as numeric UID + dbusPath, // Set environment to pick up live user session bus + "gsettings", "set", "org.gnome.desktop.input-sources" // Command, still needs a key and a value after this + }; + // clang-format on + + QStringList sources { concatLayoutAndVariant( settings.selectedLayout, settings.selectedVariant ) }; + + // Case for ukrainian homophonic keyboard for exemple + // need to configure 2 keyboards and a toggle key + // gsettings set org.gnome.desktop.input-sources sources "[('xkb', 'uk+latin1'), ('xkb','en')]" + // gsettings set org.gnome.desktop.input-sources xkb-options "['grp:lalt_lshift_toggle']" + if ( !extra.additionalLayout.isEmpty() ) + { + // Get a reasonable value for the group switcher, defaulting to alt_shift_toggle if nothing else is set + if ( !settings.selectedGroup.isEmpty() ) + { + extra.groupSwitcher = "grp:" + settings.selectedGroup; + } + if ( extra.groupSwitcher.isEmpty() ) + { + extra.groupSwitcher = xkbmap_query_grp_option(); + } + if ( extra.groupSwitcher.isEmpty() ) + { + extra.groupSwitcher = "grp:alt_shift_toggle"; + } + + const QString xkbOptionsValue = QStringLiteral( "['%1']" ).arg( extra.groupSwitcher ); + const QStringList xkbOptionsCommand = QStringList( sudoArguments ) << "xkb-options" << xkbOptionsValue; + QProcess::execute( "sudo", xkbOptionsCommand ); + cDebug() << "Executed: sudo" << xkbOptionsCommand; + + // And add additional layout to the sources-list + sources.append( concatLayoutAndVariant( extra.additionalLayout, extra.additionalVariant ) ); + } + + const QStringList sourcesCommand = QStringList( sudoArguments ) << "sources" << squareBracketedList( sources ); + QProcess::execute( "sudo", sourcesCommand ); + cDebug() << "Executed: sudo" << sourcesCommand; +} + + +void +Config::apply() +{ + m_additionalLayoutInfo = getAdditionalLayoutInfo( m_current.selectedLayout ); + if ( m_configureXkb ) + { + applyXkb( m_current, m_additionalLayoutInfo ); + } + if ( m_configureLocale1 ) + { + applyLocale1( m_current, m_additionalLayoutInfo ); + } + if ( m_configureKWin ) + { + applyKWin( m_current, m_additionalLayoutInfo ); + } + if ( m_configureGnome ) + { + applyGnome( m_current, m_additionalLayoutInfo ); + } + m_applyTimer.stop(); + // Writing /etc/ files is not needed "live" +} + +KeyboardModelsModel* +Config::keyboardModels() const +{ + return m_keyboardModelsModel; +} + +KeyboardLayoutModel* +Config::keyboardLayouts() const +{ + return m_keyboardLayoutsModel; +} + +KeyboardVariantsModel* +Config::keyboardVariants() const +{ + return m_keyboardVariantsModel; +} + +KeyboardGroupsSwitchersModel* +Config::keyboardGroupsSwitchers() const +{ + return m_KeyboardGroupSwitcherModel; +} + +static QPersistentModelIndex +findLayout( const KeyboardLayoutModel* klm, const QString& currentLayout ) +{ + QPersistentModelIndex currentLayoutItem; + + for ( int i = 0; i < klm->rowCount(); ++i ) + { + QModelIndex idx = klm->index( i ); + if ( idx.isValid() && idx.data( KeyboardLayoutModel::KeyboardLayoutKeyRole ).toString() == currentLayout ) + { + currentLayoutItem = idx; + } + } + + return currentLayoutItem; +} + +void +Config::getCurrentKeyboardLayoutXkb( QString& currentLayout, QString& currentVariant, QString& currentModel ) +{ + QProcess process; + process.start( "setxkbmap", QStringList() << "-print" ); + if ( process.waitForFinished() ) + { + const QStringList list = QString( process.readAll() ).split( "\n", SplitSkipEmptyParts ); + + // A typical line looks like + // xkb_symbols { include "pc+latin+ru:2+inet(evdev)+group(alt_shift_toggle)+ctrl(swapcaps)" }; + for ( const auto& line : list ) + { + bool symbols = false; + if ( line.trimmed().startsWith( "xkb_symbols" ) ) + { + symbols = true; + } + else if ( !line.trimmed().startsWith( "xkb_geometry" ) ) + { + continue; + } + + int firstQuote = line.indexOf( '"' ); + int lastQuote = line.lastIndexOf( '"' ); + + if ( firstQuote < 0 || lastQuote < 0 || lastQuote <= firstQuote ) + { + continue; + } + + QStringList split = line.mid( firstQuote + 1, lastQuote - firstQuote ).split( "+", SplitSkipEmptyParts ); + cDebug() << split; + if ( symbols && split.size() >= 2 ) + { + currentLayout = split.at( 1 ); + + if ( currentLayout.contains( "(" ) ) + { + int parenthesisIndex = currentLayout.indexOf( "(" ); + currentVariant = currentLayout.mid( parenthesisIndex + 1 ).trimmed(); + currentVariant.chop( 1 ); + currentLayout = currentLayout.mid( 0, parenthesisIndex ).trimmed(); + } + + break; + } + else if ( !symbols && split.size() >= 1 ) + { + currentModel = split.at( 0 ); + if ( currentModel.contains( "(" ) ) + { + int parenthesisIndex = currentLayout.indexOf( "(" ); + currentModel = currentModel.mid( parenthesisIndex + 1 ).trimmed(); + currentModel.chop( 1 ); + } + } + } + } +} + +void +Config::getCurrentKeyboardLayoutLocale1( QString& currentLayout, QString& currentVariant, QString& currentModel ) +{ + QDBusInterface locale1( "org.freedesktop.locale1", + "/org/freedesktop/locale1", + "org.freedesktop.locale1", + QDBusConnection::systemBus() ); + if ( !locale1.isValid() ) + { + cWarning() << "Interface" << locale1.interface() << "is not valid."; + return; + } + + currentLayout = locale1.property( "X11Layout" ).toString().split( "," ).last(); + currentVariant = locale1.property( "X11Variant" ).toString().split( "," ).last(); + currentModel = locale1.property( "X11Model" ).toString(); +} + +void +Config::detectCurrentKeyboardLayout() +{ + if ( m_state != State::Initial ) + { + return; + } + cScopedAssignment returnToIntial( &m_state, State::Initial ); + m_state = State::Guessing; + + //### Detect current keyboard layout, variant, and model + QString currentLayout; + QString currentVariant; + QString currentModel; + + if ( m_configureLocale1 ) + { + getCurrentKeyboardLayoutLocale1( currentLayout, currentVariant, currentModel ); + } + else + { + getCurrentKeyboardLayoutXkb( currentLayout, currentVariant, currentModel ); + } + + //### Layouts and Variants + QPersistentModelIndex currentLayoutItem = findLayout( m_keyboardLayoutsModel, currentLayout ); + if ( !currentLayoutItem.isValid() && ( ( currentLayout == "latin" ) || ( currentLayout == "pc" ) ) ) + { + currentLayout = "us"; + currentLayoutItem = findLayout( m_keyboardLayoutsModel, currentLayout ); + } + + // Set current layout and variant + if ( currentLayoutItem.isValid() ) + { + m_keyboardLayoutsModel->setCurrentIndex( currentLayoutItem.row() ); + updateVariants( currentLayoutItem, currentVariant ); + } + + // Default to the first available layout if none was set + // Do this after unblocking signals so we get the default variant handling. + if ( !currentLayoutItem.isValid() && m_keyboardLayoutsModel->rowCount() > 0 ) + { + m_keyboardLayoutsModel->setCurrentIndex( m_keyboardLayoutsModel->index( 0 ).row() ); + } + + //### Keyboard model + for ( int i = 0; i < m_keyboardModelsModel->rowCount(); ++i ) + { + QModelIndex idx = m_keyboardModelsModel->index( i ); + if ( idx.isValid() && idx.data( XKBListModel::KeyRole ).toString() == currentModel ) + { + m_keyboardModelsModel->setCurrentIndex( idx.row() ); + break; + } + } + // The models have updated the m_current settings, copy them + m_original = m_current; +} + +void +Config::cancel() +{ + const auto extra = getAdditionalLayoutInfo( m_original.selectedLayout ); + if ( m_configureXkb ) + { + applyXkb( m_original, m_additionalLayoutInfo ); + } + if ( m_configureLocale1 ) + { + applyLocale1( m_original, m_additionalLayoutInfo ); + } + if ( m_configureKWin ) + { + applyKWin( m_original, m_additionalLayoutInfo ); + } + if ( m_configureGnome ) + { + applyGnome( m_original, m_additionalLayoutInfo ); + } +} + +QString +Config::prettyStatus() const +{ + QString status + = tr( "Keyboard model has been set to %1.", "@label, %1 is keyboard model, as in Apple Magic Keyboard" ) + .arg( m_keyboardModelsModel->label( m_keyboardModelsModel->currentIndex() ) ); + status += QStringLiteral( "
" ); + + QString layout = m_keyboardLayoutsModel->item( m_keyboardLayoutsModel->currentIndex() ).second.description; + QString variant = m_keyboardVariantsModel->currentIndex() >= 0 + ? m_keyboardVariantsModel->label( m_keyboardVariantsModel->currentIndex() ) + : QString( "" ); + status += tr( "Keyboard layout has been set to %1/%2.", "@label, %1 is layout, %2 is layout variant" ) + .arg( layout, variant ); + + return status; +} + +Calamares::JobList +Config::createJobs() +{ + QList< Calamares::job_ptr > list; + + Calamares::Job* j = new SetKeyboardLayoutJob( m_current.selectedModel, + m_current.selectedLayout, + m_current.selectedVariant, + m_additionalLayoutInfo, + m_xOrgConfFileName, + m_convertedKeymapPath, + m_configureEtcDefaultKeyboard, + m_configureLocale1 ); + list.append( Calamares::job_ptr( j ) ); + + return list; +} + +static void +guessLayout( const QStringList& langParts, KeyboardLayoutModel* layouts, KeyboardVariantsModel* variants ) +{ + bool foundCountryPart = false; + for ( auto countryPart = langParts.rbegin(); !foundCountryPart && countryPart != langParts.rend(); ++countryPart ) + { + cDebug() << Logger::SubEntry << "looking for locale part" << *countryPart; + for ( int i = 0; i < layouts->rowCount(); ++i ) + { + QModelIndex idx = layouts->index( i ); + QString name + = idx.isValid() ? idx.data( KeyboardLayoutModel::KeyboardLayoutKeyRole ).toString() : QString(); + if ( idx.isValid() && ( name.compare( *countryPart, Qt::CaseInsensitive ) == 0 ) ) + { + cDebug() << Logger::SubEntry << "matched" << name; + layouts->setCurrentIndex( i ); + foundCountryPart = true; + break; + } + } + if ( foundCountryPart ) + { + ++countryPart; + if ( countryPart != langParts.rend() ) + { + cDebug() << "Next level:" << *countryPart; + for ( int variantnumber = 0; variantnumber < variants->rowCount(); ++variantnumber ) + { + if ( variants->key( variantnumber ).compare( *countryPart, Qt::CaseInsensitive ) == 0 ) + { + variants->setCurrentIndex( variantnumber ); + cDebug() << Logger::SubEntry << "matched variant" << *countryPart << ' ' + << variants->key( variantnumber ); + } + } + } + } + } +} + +void +Config::guessLocaleKeyboardLayout() +{ + if ( m_state != State::Initial || !m_guessLayout ) + { + return; + } + cScopedAssignment returnToIntial( &m_state, State::Initial ); + m_state = State::Guessing; + + /* Guessing a keyboard layout based on the locale means + * mapping between language identifiers in _ + * format to keyboard mappings, which are _ + * format; in addition, some countries have multiple languages, + * so fr_BE and nl_BE want different layouts (both Belgian) + * and sometimes the language-country name doesn't match the + * keyboard-country name at all (e.g. Ellas vs. Greek). + * + * This is a table of language-to-keyboard mappings. The + * language identifier is the key, while the value is + * a string that is used instead of the real language + * identifier in guessing -- so it should be something + * like _. + */ + static constexpr char arabic[] = "ara"; + static const auto specialCaseMap = QMap< std::string, std::string >( { + /* Most Arab countries map to Arabic keyboard (Default) */ + { "ar_AE", arabic }, + { "ar_BH", arabic }, + { "ar_DZ", arabic }, + { "ar_EG", arabic }, + { "ar_IN", arabic }, + { "ar_IQ", arabic }, + { "ar_JO", arabic }, + { "ar_KW", arabic }, + { "ar_LB", arabic }, + { "ar_LY", arabic }, + /* Not Morocco: use layout ma */ + { "ar_OM", arabic }, + { "ar_QA", arabic }, + { "ar_SA", arabic }, + { "ar_SD", arabic }, + { "ar_SS", arabic }, + /* Not Syria: use layout sy */ + { "ar_TN", arabic }, + { "ar_YE", arabic }, + { "ca_ES", "cat_ES" }, /* Catalan */ + { "en_CA", "us" }, /* Canadian English */ + { "el_CY", "gr" }, /* Greek in Cyprus */ + { "el_GR", "gr" }, /* Greek in Greece */ + { "ig_NG", "igbo_NG" }, /* Igbo in Nigeria */ + { "ha_NG", "hausa_NG" }, /* Hausa */ + { "en_IN", "us" }, /* India, US English keyboards are common in India */ + } ); + + // Try to preselect a layout, depending on language and locale + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + QString lang = Calamares::Locale::readGS( *gs, QStringLiteral( "LANG" ) ); + + cDebug() << "Got locale language" << lang; + if ( !lang.isEmpty() ) + { + // Chop off .codeset and @modifier + int index = lang.indexOf( '.' ); + if ( index >= 0 ) + { + lang.truncate( index ); + } + index = lang.indexOf( '@' ); + if ( index >= 0 ) + { + lang.truncate( index ); + } + + lang.replace( '-', '_' ); // Normalize separators + } + if ( !lang.isEmpty() ) + { + std::string lang_s = lang.toStdString(); + if ( specialCaseMap.contains( lang_s ) ) + { + QString newLang = QString::fromStdString( specialCaseMap.value( lang_s ) ); + cDebug() << Logger::SubEntry << "special case language" << lang << "becomes" << newLang; + lang = newLang; + } + } + if ( !lang.isEmpty() ) + { + guessLayout( lang.split( '_', SplitSkipEmptyParts ), m_keyboardLayoutsModel, m_keyboardVariantsModel ); + } +} + +void +Config::finalize() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( !m_current.selectedLayout.isEmpty() ) + { + gs->insert( "keyboardLayout", m_current.selectedLayout ); + gs->insert( "keyboardVariant", m_current.selectedVariant ); //empty means default variant + + if ( !m_additionalLayoutInfo.additionalLayout.isEmpty() ) + { + gs->insert( "keyboardAdditionalLayout", m_additionalLayoutInfo.additionalLayout ); + gs->insert( "keyboardAdditionalVariant", m_additionalLayoutInfo.additionalVariant ); + gs->insert( "keyboardGroupSwitcher", m_additionalLayoutInfo.groupSwitcher ); + gs->insert( "keyboardVConsoleKeymap", m_additionalLayoutInfo.vconsoleKeymap ); + } + } + + //FIXME: also store keyboard model for something? +} + +void +Config::updateVariants( const QPersistentModelIndex& currentItem, QString currentVariant ) +{ + const auto variants = m_keyboardLayoutsModel->item( currentItem.row() ).second.variants; + m_keyboardVariantsModel->setVariants( variants ); + + auto index = -1; + for ( const auto& key : variants.keys() ) + { + index++; + if ( variants[ key ] == currentVariant ) + { + m_keyboardVariantsModel->setCurrentIndex( index ); + return; + } + } +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + using namespace Calamares; + bool isX11 = QGuiApplication::platformName() == "xcb"; + + const auto xorgConfDefault = QStringLiteral( "00-keyboard.conf" ); + m_xOrgConfFileName = getString( configurationMap, "xOrgConfFileName", xorgConfDefault ); + if ( m_xOrgConfFileName.isEmpty() ) + { + m_xOrgConfFileName = xorgConfDefault; + } + m_convertedKeymapPath = getString( configurationMap, "convertedKeymapPath" ); + m_configureEtcDefaultKeyboard = getBool( configurationMap, "writeEtcDefaultKeyboard", true ); + m_configureLocale1 = getBool( configurationMap, "useLocale1", !isX11 ); + + bool bogus = false; + const auto configureItems = getSubMap( configurationMap, "configure", bogus ); + m_configureKWin = getBool( configureItems, "kwin", false ); + m_configureGnome = getBool( configureItems, "gnome", false ); + + m_guessLayout = getBool( configurationMap, "guessLayout", true ); +} + +void +Config::retranslate() +{ + retranslateKeyboardModels(); +} + +void +Config::selectionChange() +{ + if ( m_state == State::Initial ) + { + m_state = State::UserSelected; + } +} diff --git a/calamares/src/modules/keyboard/Config.h b/calamares/src/modules/keyboard/Config.h new file mode 100644 index 0000000..3a2549a --- /dev/null +++ b/calamares/src/modules/keyboard/Config.h @@ -0,0 +1,147 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 Camilo Higuita + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARD_CONFIG_H +#define KEYBOARD_CONFIG_H + +#include "AdditionalLayoutInfo.h" +#include "Job.h" +#include "KeyboardLayoutModel.h" + +#include +#include +#include +#include +#include + +class Config : public QObject +{ + Q_OBJECT + Q_PROPERTY( KeyboardModelsModel* keyboardModelsModel READ keyboardModels CONSTANT FINAL ) + Q_PROPERTY( KeyboardLayoutModel* keyboardLayoutsModel READ keyboardLayouts CONSTANT FINAL ) + Q_PROPERTY( KeyboardVariantsModel* keyboardVariantsModel READ keyboardVariants CONSTANT FINAL ) + Q_PROPERTY( KeyboardGroupsSwitchersModel* keyboardGroupsSwitchersModel READ keyboardGroupsSwitchers CONSTANT FINAL ) + Q_PROPERTY( QString prettyStatus READ prettyStatus NOTIFY prettyStatusChanged FINAL ) + +public: + Config( QObject* parent = nullptr ); + + /// @brief Based on current xkb settings, pick a layout + void detectCurrentKeyboardLayout(); + /// @brief Based on current locale, pick a layout + void guessLocaleKeyboardLayout(); + + Calamares::JobList createJobs(); + QString prettyStatus() const; + + /// @brief When leaving the page, write to GS + void finalize(); + + /// @brief Restore the system to whatever layout was in use when detectCurrentKeyboardLayout() was called + void cancel(); + + static AdditionalLayoutInfo getAdditionalLayoutInfo( const QString& layout ); + + /* A model is a physical configuration of a keyboard, e.g. 105-key PC + * or TKL 88-key physical size. + */ + KeyboardModelsModel* keyboardModels() const; + /* A layout describes the basic keycaps / language assigned to the + * keys of the physical keyboard, e.g. English (US) or Russian. + */ + KeyboardLayoutModel* keyboardLayouts() const; + /* A variant describes a variant of the basic keycaps; this can + * concern options (dead keys), or different placements of the keycaps + * (dvorak). + */ + KeyboardVariantsModel* keyboardVariants() const; + /* A group describes a toggle groups of change layouts + */ + KeyboardGroupsSwitchersModel* keyboardGroupsSwitchers() const; + + /** @brief Call this to change application language + * + * The models (for keyboard model, layouts and variants) provide + * translations of strings in the xkb table, so need to be + * notified of language changes as well. + * + * Only widgets get LanguageChange events, so one of them will + * need to call this. + */ + void retranslate(); + + void setConfigurationMap( const QVariantMap& configurationMap ); + +signals: + void prettyStatusChanged(); + +private: + void updateVariants( const QPersistentModelIndex& currentItem, QString currentVariant = QString() ); + + /* These two methods are used in tandem to apply changes to the + * keyboard layout. This introduces a slight delay between selecting + * a keyboard, and applying it to the system -- so that if you + * scroll through or down-arrow through the list of keyboards, + * you don't get buried under updates which might take some time. + * + * somethingChanged() is called when the selection changes, and triggers + * a delayed call to apply() which does the actual work by calling the + * relevant apply*() functions. + */ + void somethingChanged(); + void apply(); + + void getCurrentKeyboardLayoutXkb( QString& currentLayout, QString& currentVariant, QString& currentModel ); + void getCurrentKeyboardLayoutLocale1( QString& currentLayout, QString& currentVariant, QString& currentModel ); + + KeyboardModelsModel* m_keyboardModelsModel; + KeyboardLayoutModel* m_keyboardLayoutsModel; + KeyboardVariantsModel* m_keyboardVariantsModel; + KeyboardGroupsSwitchersModel* m_KeyboardGroupSwitcherModel; + + BasicLayoutInfo m_current; + BasicLayoutInfo m_original; + + // Layout (and corresponding info) added if current one doesn't support ASCII (e.g. Russian or Japanese) + AdditionalLayoutInfo m_additionalLayoutInfo; + + QTimer m_applyTimer; + + // From configuration + QString m_xOrgConfFileName; + QString m_convertedKeymapPath; + bool m_configureXkb = true; + bool m_configureEtcDefaultKeyboard = true; + bool m_configureLocale1 = false; + bool m_configureKWin = false; + bool m_configureGnome = false; + bool m_guessLayout = false; + + // The state determines whether we guess settings or preserve them: + // - Initial -> Guessing + // - Initial -> UserSelected + // - Guessing -> Initial + enum class State + { + Initial, // after configuration, nothing special going on + Guessing, // on activation + UserSelected // explicit choice is made, preserve that + }; + State m_state = State::Initial; + + /** @brief Handles state change when selections in model, variant, layout + * + * This handles the Initial -> UserSelected transition in particular. + */ + void selectionChange(); +}; + + +#endif diff --git a/calamares/src/modules/keyboard/KeyboardData_p.cxxtr b/calamares/src/modules/keyboard/KeyboardData_p.cxxtr new file mode 100644 index 0000000..2067536 --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardData_p.cxxtr @@ -0,0 +1,880 @@ +/* GENERATED FILE DO NOT EDIT +* +* === This file is part of Calamares - === +* +* SPDX-FileCopyrightText: no +* SPDX-License-Identifier: CC0-1.0 +* +* This file is derived from base.lst in the Xorg distribution +* +*/ + +/** THIS FILE EXISTS ONLY FOR TRANSLATIONS PURPOSES **/ + +// *INDENT-OFF* +// clang-format off +/* This returns a reference to local, which is a terrible idea. + * Good thing it's not meant to be compiled. + */ +class kb_models : public QObject { +Q_OBJECT +public: + const QStringList& table() + { + return QStringList { + tr("A4Tech KB-21", "kb_models"), + tr("A4Tech KBS-8", "kb_models"), + tr("A4Tech Wireless Desktop RFKB-23", "kb_models"), + tr("Acer AirKey V", "kb_models"), + tr("Acer C300", "kb_models"), + tr("Acer Ferrari 4000", "kb_models"), + tr("Acer laptop", "kb_models"), + tr("Advance Scorpius KI", "kb_models"), + tr("Apple", "kb_models"), + tr("Apple Aluminium (ANSI)", "kb_models"), + tr("Apple Aluminium (ISO)", "kb_models"), + tr("Apple Aluminium (JIS)", "kb_models"), + tr("Apple laptop", "kb_models"), + tr("Asus laptop", "kb_models"), + tr("Azona RF2300 wireless Internet", "kb_models"), + tr("BTC 5090", "kb_models"), + tr("BTC 5113RF Multimedia", "kb_models"), + tr("BTC 5126T", "kb_models"), + tr("BTC 6301URF", "kb_models"), + tr("BTC 9000", "kb_models"), + tr("BTC 9000A", "kb_models"), + tr("BTC 9001AH", "kb_models"), + tr("BTC 9019U", "kb_models"), + tr("BTC 9116U Mini Wireless Internet and Gaming", "kb_models"), + tr("BenQ X-Touch", "kb_models"), + tr("BenQ X-Touch 730", "kb_models"), + tr("BenQ X-Touch 800", "kb_models"), + tr("Brother Internet", "kb_models"), + tr("Cherry B.UNLIMITED", "kb_models"), + tr("Cherry Blue Line CyBo@rd", "kb_models"), + tr("Cherry Blue Line CyBo@rd (alt.)", "kb_models"), + tr("Cherry CyBo@rd USB-Hub", "kb_models"), + tr("Cherry CyMotion Expert", "kb_models"), + tr("Cherry CyMotion Master Linux", "kb_models"), + tr("Cherry CyMotion Master XPress", "kb_models"), + tr("Chicony Internet", "kb_models"), + tr("Chicony KB-9885", "kb_models"), + tr("Chicony KU-0108", "kb_models"), + tr("Chicony KU-0420", "kb_models"), + tr("Chromebook", "kb_models"), + tr("Classmate PC", "kb_models"), + tr("Compaq Armada laptop", "kb_models"), + tr("Compaq Easy Access", "kb_models"), + tr("Compaq Internet (13 keys)", "kb_models"), + tr("Compaq Internet (18 keys)", "kb_models"), + tr("Compaq Internet (7 keys)", "kb_models"), + tr("Compaq Presario laptop", "kb_models"), + tr("Compaq iPaq", "kb_models"), + tr("Creative Desktop Wireless 7000", "kb_models"), + tr("DTK2000", "kb_models"), + tr("Dell", "kb_models"), + tr("Dell 101-key PC", "kb_models"), + tr("Dell Inspiron 6000/8000 laptop", "kb_models"), + tr("Dell Latitude laptop", "kb_models"), + tr("Dell Precision M laptop", "kb_models"), + tr("Dell Precision M65 laptop", "kb_models"), + tr("Dell SK-8125", "kb_models"), + tr("Dell SK-8135", "kb_models"), + tr("Dell USB Multimedia", "kb_models"), + tr("Dexxa Wireless Desktop", "kb_models"), + tr("Diamond 9801/9802", "kb_models"), + tr("Ennyah DKB-1008", "kb_models"), + tr("Everex STEPnote", "kb_models"), + tr("FL90", "kb_models"), + tr("Fujitsu-Siemens Amilo laptop", "kb_models"), + tr("Generic 101-key PC", "kb_models"), + tr("Generic 102-key PC", "kb_models"), + tr("Generic 104-key PC", "kb_models"), + tr("Generic 104-key PC with L-shaped Enter key", "kb_models"), + tr("Generic 105-key PC", "kb_models"), + tr("Generic 86-key PC", "kb_models"), + tr("Genius Comfy KB-12e", "kb_models"), + tr("Genius Comfy KB-16M/Multimedia KWD-910", "kb_models"), + tr("Genius Comfy KB-21e-Scroll", "kb_models"), + tr("Genius KB-19e NB", "kb_models"), + tr("Genius KKB-2050HS", "kb_models"), + tr("Gyration", "kb_models"), + tr("Happy Hacking", "kb_models"), + tr("Happy Hacking for Mac", "kb_models"), + tr("Hewlett-Packard Internet", "kb_models"), + tr("Hewlett-Packard Mini 110 laptop", "kb_models"), + tr("Hewlett-Packard NEC SK-2500 Multimedia", "kb_models"), + tr("Hewlett-Packard Omnibook 500", "kb_models"), + tr("Hewlett-Packard Omnibook 500 FA", "kb_models"), + tr("Hewlett-Packard Omnibook 6000/6100", "kb_models"), + tr("Hewlett-Packard Omnibook XE3 GC", "kb_models"), + tr("Hewlett-Packard Omnibook XE3 GF", "kb_models"), + tr("Hewlett-Packard Omnibook XT1000", "kb_models"), + tr("Hewlett-Packard Pavilion ZT1100", "kb_models"), + tr("Hewlett-Packard Pavilion dv5", "kb_models"), + tr("Hewlett-Packard nx9020", "kb_models"), + tr("Honeywell Euroboard", "kb_models"), + tr("IBM Rapid Access", "kb_models"), + tr("IBM Rapid Access II", "kb_models"), + tr("IBM Space Saver", "kb_models"), + tr("IBM ThinkPad 560Z/600/600E/A22E", "kb_models"), + tr("IBM ThinkPad R60/T60/R61/T61", "kb_models"), + tr("IBM ThinkPad Z60m/Z60t/Z61m/Z61t", "kb_models"), + tr("Keytronic FlexPro", "kb_models"), + tr("Kinesis", "kb_models"), + tr("Logitech", "kb_models"), + tr("Logitech Access", "kb_models"), + tr("Logitech Cordless Desktop", "kb_models"), + tr("Logitech Cordless Desktop (alt.)", "kb_models"), + tr("Logitech Cordless Desktop EX110", "kb_models"), + tr("Logitech Cordless Desktop LX-300", "kb_models"), + tr("Logitech Cordless Desktop Navigator", "kb_models"), + tr("Logitech Cordless Desktop Optical", "kb_models"), + tr("Logitech Cordless Desktop Pro (2nd alt.)", "kb_models"), + tr("Logitech Cordless Desktop iTouch", "kb_models"), + tr("Logitech Cordless Freedom/Desktop Navigator", "kb_models"), + tr("Logitech G15 extra keys via G15daemon", "kb_models"), + tr("Logitech Internet", "kb_models"), + tr("Logitech Internet 350", "kb_models"), + tr("Logitech Internet Navigator", "kb_models"), + tr("Logitech Ultra-X", "kb_models"), + tr("Logitech Ultra-X Cordless Media Desktop", "kb_models"), + tr("Logitech diNovo", "kb_models"), + tr("Logitech diNovo Edge", "kb_models"), + tr("Logitech iTouch", "kb_models"), + tr("Logitech iTouch Cordless Y-RB6", "kb_models"), + tr("Logitech iTouch Internet Navigator SE", "kb_models"), + tr("Logitech iTouch Internet Navigator SE USB", "kb_models"), + tr("MacBook/MacBook Pro", "kb_models"), + tr("MacBook/MacBook Pro (intl.)", "kb_models"), + tr("Macintosh", "kb_models"), + tr("Macintosh Old", "kb_models"), + tr("Memorex MX1998", "kb_models"), + tr("Memorex MX2500 EZ-Access", "kb_models"), + tr("Memorex MX2750", "kb_models"), + tr("Microsoft Comfort Curve 2000", "kb_models"), + tr("Microsoft Internet", "kb_models"), + tr("Microsoft Internet Pro (Swedish)", "kb_models"), + tr("Microsoft Natural", "kb_models"), + tr("Microsoft Natural Elite", "kb_models"), + tr("Microsoft Natural Ergonomic 4000", "kb_models"), + tr("Microsoft Natural Pro OEM", "kb_models"), + tr("Microsoft Natural Pro USB/Internet Pro", "kb_models"), + tr("Microsoft Natural Pro/Internet Pro", "kb_models"), + tr("Microsoft Natural Wireless Ergonomic 7000", "kb_models"), + tr("Microsoft Office Keyboard", "kb_models"), + tr("Microsoft Surface", "kb_models"), + tr("Microsoft Wireless Multimedia 1.0A", "kb_models"), + tr("NEC SK-1300", "kb_models"), + tr("NEC SK-2500", "kb_models"), + tr("NEC SK-6200", "kb_models"), + tr("NEC SK-7100", "kb_models"), + tr("Northgate OmniKey 101", "kb_models"), + tr("OLPC", "kb_models"), + tr("Ortek Multimedia/Internet MCK-800", "kb_models"), + tr("PC-98", "kb_models"), + tr("Propeller Voyager KTEZ-1000", "kb_models"), + tr("QTronix Scorpius 98N+", "kb_models"), + tr("SVEN Ergonomic 2500", "kb_models"), + tr("SVEN Slim 303", "kb_models"), + tr("Samsung SDM 4500P", "kb_models"), + tr("Samsung SDM 4510P", "kb_models"), + tr("Sanwa Supply SKB-KG3", "kb_models"), + tr("Silvercrest Multimedia Wireless", "kb_models"), + tr("SteelSeries Apex 300 (Apex RAW)", "kb_models"), + tr("Sun Type 6 (Japanese)", "kb_models"), + tr("Sun Type 6 USB (Japanese)", "kb_models"), + tr("Sun Type 6 USB (Unix)", "kb_models"), + tr("Sun Type 6/7 USB", "kb_models"), + tr("Sun Type 6/7 USB (European)", "kb_models"), + tr("Sun Type 7 USB", "kb_models"), + tr("Sun Type 7 USB (European)", "kb_models"), + tr("Sun Type 7 USB (Japanese)/Japanese 106-key", "kb_models"), + tr("Sun Type 7 USB (Unix)", "kb_models"), + tr("Super Power Multimedia", "kb_models"), + tr("Symplon PaceBook tablet", "kb_models"), + tr("Targa Visionary 811", "kb_models"), + tr("Toshiba Satellite S3000", "kb_models"), + tr("Truly Ergonomic 227", "kb_models"), + tr("Truly Ergonomic 229", "kb_models"), + tr("Truly Ergonomic Computer Keyboard Model 227 (Wide Alt keys)", "kb_models"), + tr("Truly Ergonomic Computer Keyboard Model 229 (Standard sized Alt keys, additional Super and Menu key)", "kb_models"), + tr("Trust Direct Access", "kb_models"), + tr("Trust Slimline", "kb_models"), + tr("Trust Wireless Classic", "kb_models"), + tr("TypeMatrix EZ-Reach 2020", "kb_models"), + tr("TypeMatrix EZ-Reach 2030 PS2", "kb_models"), + tr("TypeMatrix EZ-Reach 2030 USB", "kb_models"), + tr("TypeMatrix EZ-Reach 2030 USB (102/105:EU mode)", "kb_models"), + tr("TypeMatrix EZ-Reach 2030 USB (106:JP mode)", "kb_models"), + tr("Unitek KB-1925", "kb_models"), + tr("ViewSonic KU-306 Internet", "kb_models"), + tr("Winbook Model XP5", "kb_models"), + tr("Yahoo! Internet", "kb_models"), + tr("eMachines m6800 laptop", "kb_models"), + QString() + }; +} +} + +/* This returns a reference to local, which is a terrible idea. + * Good thing it's not meant to be compiled. + */ +class kb_layouts : public QObject { +Q_OBJECT +public: + const QStringList& table() + { + return QStringList { + tr("Afghani", "kb_layouts"), + tr("Albanian", "kb_layouts"), + tr("Amharic", "kb_layouts"), + tr("Arabic", "kb_layouts"), + tr("Arabic (Morocco)", "kb_layouts"), + tr("Arabic (Syria)", "kb_layouts"), + tr("Armenian", "kb_layouts"), + tr("Azerbaijani", "kb_layouts"), + tr("Bambara", "kb_layouts"), + tr("Bangla", "kb_layouts"), + tr("Belarusian", "kb_layouts"), + tr("Belgian", "kb_layouts"), + tr("Bosnian", "kb_layouts"), + tr("Braille", "kb_layouts"), + tr("Bulgarian", "kb_layouts"), + tr("Burmese", "kb_layouts"), + tr("Chinese", "kb_layouts"), + tr("Croatian", "kb_layouts"), + tr("Czech", "kb_layouts"), + tr("Danish", "kb_layouts"), + tr("Dhivehi", "kb_layouts"), + tr("Dutch", "kb_layouts"), + tr("Dzongkha", "kb_layouts"), + tr("English (Australian)", "kb_layouts"), + tr("English (Cameroon)", "kb_layouts"), + tr("English (Ghana)", "kb_layouts"), + tr("English (Nigeria)", "kb_layouts"), + tr("English (South Africa)", "kb_layouts"), + tr("English (UK)", "kb_layouts"), + tr("English (US)", "kb_layouts"), + tr("Esperanto", "kb_layouts"), + tr("Estonian", "kb_layouts"), + tr("Faroese", "kb_layouts"), + tr("Filipino", "kb_layouts"), + tr("Finnish", "kb_layouts"), + tr("French", "kb_layouts"), + tr("French (Canada)", "kb_layouts"), + tr("French (Democratic Republic of the Congo)", "kb_layouts"), + tr("French (Guinea)", "kb_layouts"), + tr("French (Togo)", "kb_layouts"), + tr("Georgian", "kb_layouts"), + tr("German", "kb_layouts"), + tr("German (Austria)", "kb_layouts"), + tr("German (Switzerland)", "kb_layouts"), + tr("Greek", "kb_layouts"), + tr("Hebrew", "kb_layouts"), + tr("Hungarian", "kb_layouts"), + tr("Icelandic", "kb_layouts"), + tr("Indian", "kb_layouts"), + tr("Indonesian (Arab Melayu, phonetic)", "kb_layouts"), + tr("Indonesian (Javanese)", "kb_layouts"), + tr("Iraqi", "kb_layouts"), + tr("Irish", "kb_layouts"), + tr("Italian", "kb_layouts"), + tr("Japanese", "kb_layouts"), + tr("Japanese (PC-98)", "kb_layouts"), + tr("Kabylian (azerty layout, no dead keys)", "kb_layouts"), + tr("Kazakh", "kb_layouts"), + tr("Khmer (Cambodia)", "kb_layouts"), + tr("Korean", "kb_layouts"), + tr("Kyrgyz", "kb_layouts"), + tr("Lao", "kb_layouts"), + tr("Latvian", "kb_layouts"), + tr("Lithuanian", "kb_layouts"), + tr("Macedonian", "kb_layouts"), + tr("Malay (Jawi, Arabic Keyboard)", "kb_layouts"), + tr("Maltese", "kb_layouts"), + tr("Maori", "kb_layouts"), + tr("Moldavian", "kb_layouts"), + tr("Mongolian", "kb_layouts"), + tr("Montenegrin", "kb_layouts"), + tr("Nepali", "kb_layouts"), + tr("Norwegian", "kb_layouts"), + tr("Persian", "kb_layouts"), + tr("Polish", "kb_layouts"), + tr("Portuguese", "kb_layouts"), + tr("Portuguese (Brazil)", "kb_layouts"), + tr("Romanian", "kb_layouts"), + tr("Russian", "kb_layouts"), + tr("Serbian", "kb_layouts"), + tr("Sinhala (phonetic)", "kb_layouts"), + tr("Slovak", "kb_layouts"), + tr("Slovenian", "kb_layouts"), + tr("Spanish", "kb_layouts"), + tr("Spanish (Latin American)", "kb_layouts"), + tr("Swahili (Kenya)", "kb_layouts"), + tr("Swahili (Tanzania)", "kb_layouts"), + tr("Swedish", "kb_layouts"), + tr("Taiwanese", "kb_layouts"), + tr("Tajik", "kb_layouts"), + tr("Thai", "kb_layouts"), + tr("Tswana", "kb_layouts"), + tr("Turkish", "kb_layouts"), + tr("Turkmen", "kb_layouts"), + tr("Ukrainian", "kb_layouts"), + tr("Urdu (Pakistan)", "kb_layouts"), + tr("Uzbek", "kb_layouts"), + tr("Vietnamese", "kb_layouts"), + tr("Wolof", "kb_layouts"), + QString() + }; +} +} + +/* This returns a reference to local, which is a terrible idea. + * Good thing it's not meant to be compiled. + */ +class kb_variants : public QObject { +Q_OBJECT +public: + const QStringList& table() + { + return QStringList { + tr("Akan", "kb_variants"), + tr("Albanian (Plisi)", "kb_variants"), + tr("Albanian (Veqilharxhi)", "kb_variants"), + tr("Arabic (AZERTY)", "kb_variants"), + tr("Arabic (AZERTY, Eastern Arabic numerals)", "kb_variants"), + tr("Arabic (Algeria)", "kb_variants"), + tr("Arabic (Buckwalter)", "kb_variants"), + tr("Arabic (Eastern Arabic numerals)", "kb_variants"), + tr("Arabic (Macintosh)", "kb_variants"), + tr("Arabic (OLPC)", "kb_variants"), + tr("Arabic (Pakistan)", "kb_variants"), + tr("Arabic (QWERTY)", "kb_variants"), + tr("Arabic (QWERTY, Eastern Arabic numerals)", "kb_variants"), + tr("Armenian (alt. eastern)", "kb_variants"), + tr("Armenian (alt. phonetic)", "kb_variants"), + tr("Armenian (eastern)", "kb_variants"), + tr("Armenian (phonetic)", "kb_variants"), + tr("Armenian (western)", "kb_variants"), + tr("Asturian (Spain, with bottom-dot H and L)", "kb_variants"), + tr("Avatime", "kb_variants"), + tr("Azerbaijani (Cyrillic)", "kb_variants"), + tr("Bangla (India)", "kb_variants"), + tr("Bangla (India, Baishakhi Inscript)", "kb_variants"), + tr("Bangla (India, Baishakhi)", "kb_variants"), + tr("Bangla (India, Bornona)", "kb_variants"), + tr("Bangla (India, Gitanjali)", "kb_variants"), + tr("Bangla (India, Probhat)", "kb_variants"), + tr("Bangla (Probhat)", "kb_variants"), + tr("Bashkirian", "kb_variants"), + tr("Belarusian (Latin)", "kb_variants"), + tr("Belarusian (intl.)", "kb_variants"), + tr("Belarusian (legacy)", "kb_variants"), + tr("Belgian (ISO, alt.)", "kb_variants"), + tr("Belgian (Latin-9 only, alt.)", "kb_variants"), + tr("Belgian (Sun dead keys)", "kb_variants"), + tr("Belgian (Sun dead keys, alt.)", "kb_variants"), + tr("Belgian (Wang 724 AZERTY)", "kb_variants"), + tr("Belgian (alt.)", "kb_variants"), + tr("Belgian (no dead keys)", "kb_variants"), + tr("Berber (Morocco, Tifinagh alt.)", "kb_variants"), + tr("Berber (Morocco, Tifinagh extended phonetic)", "kb_variants"), + tr("Berber (Morocco, Tifinagh extended)", "kb_variants"), + tr("Berber (Morocco, Tifinagh phonetic)", "kb_variants"), + tr("Berber (Morocco, Tifinagh phonetic, alt.)", "kb_variants"), + tr("Berber (Morocco, Tifinagh)", "kb_variants"), + tr("Bosnian (US)", "kb_variants"), + tr("Bosnian (US, with Bosnian digraphs)", "kb_variants"), + tr("Bosnian (with Bosnian digraphs)", "kb_variants"), + tr("Bosnian (with guillemets)", "kb_variants"), + tr("Braille (left-handed inverted thumb)", "kb_variants"), + tr("Braille (left-handed)", "kb_variants"), + tr("Braille (right-handed inverted thumb)", "kb_variants"), + tr("Braille (right-handed)", "kb_variants"), + tr("Bulgarian (enhanced)", "kb_variants"), + tr("Bulgarian (new phonetic)", "kb_variants"), + tr("Bulgarian (traditional phonetic)", "kb_variants"), + tr("Burmese Zawgyi", "kb_variants"), + tr("Cameroon (AZERTY, intl.)", "kb_variants"), + tr("Cameroon (Dvorak, intl.)", "kb_variants"), + tr("Cameroon Multilingual (QWERTY, intl.)", "kb_variants"), + tr("Canadian (intl.)", "kb_variants"), + tr("Canadian (intl., 1st part)", "kb_variants"), + tr("Canadian (intl., 2nd part)", "kb_variants"), + tr("Catalan (Spain, with middle-dot L)", "kb_variants"), + tr("Cherokee", "kb_variants"), + tr("Chuvash", "kb_variants"), + tr("Chuvash (Latin)", "kb_variants"), + tr("CloGaelach", "kb_variants"), + tr("Crimean Tatar (Turkish Alt-Q)", "kb_variants"), + tr("Crimean Tatar (Turkish F)", "kb_variants"), + tr("Crimean Tatar (Turkish Q)", "kb_variants"), + tr("Croatian (US)", "kb_variants"), + tr("Croatian (US, with Croatian digraphs)", "kb_variants"), + tr("Croatian (with Croatian digraphs)", "kb_variants"), + tr("Croatian (with guillemets)", "kb_variants"), + tr("Czech (QWERTY)", "kb_variants"), + tr("Czech (QWERTY, Macintosh)", "kb_variants"), + tr("Czech (QWERTY, extended backslash)", "kb_variants"), + tr("Czech (UCW, only accented letters)", "kb_variants"), + tr("Czech (US, Dvorak, UCW support)", "kb_variants"), + tr("Czech (with <\|> key)", "kb_variants"), + tr("Danish (Dvorak)", "kb_variants"), + tr("Danish (Macintosh)", "kb_variants"), + tr("Danish (Macintosh, no dead keys)", "kb_variants"), + tr("Danish (Windows)", "kb_variants"), + tr("Danish (no dead keys)", "kb_variants"), + tr("Default", "kb_variants"), + tr("Dutch (Macintosh)", "kb_variants"), + tr("Dutch (Sun dead keys)", "kb_variants"), + tr("Dutch (standard)", "kb_variants"), + tr("English (Canada)", "kb_variants"), + tr("English (Colemak)", "kb_variants"), + tr("English (Dvorak)", "kb_variants"), + tr("English (Dvorak, alt. intl.)", "kb_variants"), + tr("English (Dvorak, intl., with dead keys)", "kb_variants"), + tr("English (Dvorak, left-handed)", "kb_variants"), + tr("English (Dvorak, right-handed)", "kb_variants"), + tr("English (Ghana, GILLBT)", "kb_variants"), + tr("English (Ghana, multilingual)", "kb_variants"), + tr("English (India, with rupee)", "kb_variants"), + tr("English (Macintosh)", "kb_variants"), + tr("English (Mali, US, Macintosh)", "kb_variants"), + tr("English (Mali, US, intl.)", "kb_variants"), + tr("English (Norman)", "kb_variants"), + tr("English (UK, Colemak)", "kb_variants"), + tr("English (UK, Dvorak)", "kb_variants"), + tr("English (UK, Dvorak, with UK punctuation)", "kb_variants"), + tr("English (UK, Macintosh)", "kb_variants"), + tr("English (UK, Macintosh, intl.)", "kb_variants"), + tr("English (UK, extended, Windows)", "kb_variants"), + tr("English (UK, intl., with dead keys)", "kb_variants"), + tr("English (US, Symbolic)", "kb_variants"), + tr("English (US, alt. intl.)", "kb_variants"), + tr("English (US, euro on 5)", "kb_variants"), + tr("English (US, intl., with dead keys)", "kb_variants"), + tr("English (Workman)", "kb_variants"), + tr("English (Workman, intl., with dead keys)", "kb_variants"), + tr("English (classic Dvorak)", "kb_variants"), + tr("English (intl., with AltGr dead keys)", "kb_variants"), + tr("English (programmer Dvorak)", "kb_variants"), + tr("English (the divide/multiply toggle the layout)", "kb_variants"), + tr("Esperanto (Brazil, Nativo)", "kb_variants"), + tr("Esperanto (Portugal, Nativo)", "kb_variants"), + tr("Esperanto (legacy)", "kb_variants"), + tr("Estonian (Dvorak)", "kb_variants"), + tr("Estonian (US)", "kb_variants"), + tr("Estonian (no dead keys)", "kb_variants"), + tr("Ewe", "kb_variants"), + tr("Faroese (no dead keys)", "kb_variants"), + tr("Filipino (Capewell-Dvorak, Baybayin)", "kb_variants"), + tr("Filipino (Capewell-Dvorak, Latin)", "kb_variants"), + tr("Filipino (Capewell-QWERF 2006, Baybayin)", "kb_variants"), + tr("Filipino (Capewell-QWERF 2006, Latin)", "kb_variants"), + tr("Filipino (Colemak, Baybayin)", "kb_variants"), + tr("Filipino (Colemak, Latin)", "kb_variants"), + tr("Filipino (Dvorak, Baybayin)", "kb_variants"), + tr("Filipino (Dvorak, Latin)", "kb_variants"), + tr("Filipino (QWERTY, Baybayin)", "kb_variants"), + tr("Finnish (Macintosh)", "kb_variants"), + tr("Finnish (Windows)", "kb_variants"), + tr("Finnish (classic)", "kb_variants"), + tr("Finnish (classic, no dead keys)", "kb_variants"), + tr("French (AZERTY)", "kb_variants"), + tr("French (AZERTY, AFNOR)", "kb_variants"), + tr("French (BEPO)", "kb_variants"), + tr("French (BEPO, AFNOR)", "kb_variants"), + tr("French (BEPO, Latin-9 only)", "kb_variants"), + tr("French (Breton)", "kb_variants"), + tr("French (Cameroon)", "kb_variants"), + tr("French (Canada, Dvorak)", "kb_variants"), + tr("French (Canada, legacy)", "kb_variants"), + tr("French (Dvorak)", "kb_variants"), + tr("French (Macintosh)", "kb_variants"), + tr("French (Mali, alt.)", "kb_variants"), + tr("French (Morocco)", "kb_variants"), + tr("French (Sun dead keys)", "kb_variants"), + tr("French (Switzerland)", "kb_variants"), + tr("French (Switzerland, Macintosh)", "kb_variants"), + tr("French (Switzerland, Sun dead keys)", "kb_variants"), + tr("French (Switzerland, no dead keys)", "kb_variants"), + tr("French (US)", "kb_variants"), + tr("French (alt.)", "kb_variants"), + tr("French (alt., Latin-9 only)", "kb_variants"), + tr("French (alt., Sun dead keys)", "kb_variants"), + tr("French (alt., no dead keys)", "kb_variants"), + tr("French (legacy, alt.)", "kb_variants"), + tr("French (legacy, alt., Sun dead keys)", "kb_variants"), + tr("French (legacy, alt., no dead keys)", "kb_variants"), + tr("French (no dead keys)", "kb_variants"), + tr("Friulian (Italy)", "kb_variants"), + tr("Fula", "kb_variants"), + tr("Ga", "kb_variants"), + tr("Georgian (France, AZERTY Tskapo)", "kb_variants"), + tr("Georgian (Italy)", "kb_variants"), + tr("Georgian (MESS)", "kb_variants"), + tr("Georgian (ergonomic)", "kb_variants"), + tr("German (Austria, Macintosh)", "kb_variants"), + tr("German (Austria, Sun dead keys)", "kb_variants"), + tr("German (Austria, no dead keys)", "kb_variants"), + tr("German (Dvorak)", "kb_variants"), + tr("German (E1)", "kb_variants"), + tr("German (E2)", "kb_variants"), + tr("German (Macintosh)", "kb_variants"), + tr("German (Macintosh, no dead keys)", "kb_variants"), + tr("German (Neo 2)", "kb_variants"), + tr("German (QWERTY)", "kb_variants"), + tr("German (Sun dead keys)", "kb_variants"), + tr("German (Switzerland, Macintosh)", "kb_variants"), + tr("German (Switzerland, Sun dead keys)", "kb_variants"), + tr("German (Switzerland, legacy)", "kb_variants"), + tr("German (Switzerland, no dead keys)", "kb_variants"), + tr("German (T3)", "kb_variants"), + tr("German (US)", "kb_variants"), + tr("German (dead acute)", "kb_variants"), + tr("German (dead grave acute)", "kb_variants"), + tr("German (dead tilde)", "kb_variants"), + tr("German (no dead keys)", "kb_variants"), + tr("Greek (extended)", "kb_variants"), + tr("Greek (no dead keys)", "kb_variants"), + tr("Greek (polytonic)", "kb_variants"), + tr("Greek (simple)", "kb_variants"), + tr("Gujarati", "kb_variants"), + tr("Hanyu Pinyin (with AltGr dead keys)", "kb_variants"), + tr("Hausa (Ghana)", "kb_variants"), + tr("Hausa (Nigeria)", "kb_variants"), + tr("Hawaiian", "kb_variants"), + tr("Hebrew (Biblical, Tiro)", "kb_variants"), + tr("Hebrew (lyx)", "kb_variants"), + tr("Hebrew (phonetic)", "kb_variants"), + tr("Hindi (Bolnagri)", "kb_variants"), + tr("Hindi (KaGaPa, phonetic)", "kb_variants"), + tr("Hindi (Wx)", "kb_variants"), + tr("Hungarian (QWERTY)", "kb_variants"), + tr("Hungarian (QWERTY, 101-key, comma, dead keys)", "kb_variants"), + tr("Hungarian (QWERTY, 101-key, comma, no dead keys)", "kb_variants"), + tr("Hungarian (QWERTY, 101-key, dot, dead keys)", "kb_variants"), + tr("Hungarian (QWERTY, 101-key, dot, no dead keys)", "kb_variants"), + tr("Hungarian (QWERTY, 102-key, comma, dead keys)", "kb_variants"), + tr("Hungarian (QWERTY, 102-key, comma, no dead keys)", "kb_variants"), + tr("Hungarian (QWERTY, 102-key, dot, dead keys)", "kb_variants"), + tr("Hungarian (QWERTY, 102-key, dot, no dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 101-key, comma, dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 101-key, comma, no dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 101-key, dot, dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 101-key, dot, no dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 102-key, comma, dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 102-key, comma, no dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 102-key, dot, dead keys)", "kb_variants"), + tr("Hungarian (QWERTZ, 102-key, dot, no dead keys)", "kb_variants"), + tr("Hungarian (no dead keys)", "kb_variants"), + tr("Hungarian (standard)", "kb_variants"), + tr("Icelandic (Dvorak)", "kb_variants"), + tr("Icelandic (Macintosh)", "kb_variants"), + tr("Icelandic (Macintosh, legacy)", "kb_variants"), + tr("Icelandic (Sun dead keys)", "kb_variants"), + tr("Icelandic (no dead keys)", "kb_variants"), + tr("Igbo", "kb_variants"), + tr("Indic (phonetic, IPA)", "kb_variants"), + tr("Indonesian (Arab Melayu, extended phonetic)", "kb_variants"), + tr("Inuktitut", "kb_variants"), + tr("Irish (UnicodeExpert)", "kb_variants"), + tr("Italian (IBM 142)", "kb_variants"), + tr("Italian (Macintosh)", "kb_variants"), + tr("Italian (US)", "kb_variants"), + tr("Italian (Windows)", "kb_variants"), + tr("Italian (intl., with dead keys)", "kb_variants"), + tr("Italian (no dead keys)", "kb_variants"), + tr("Japanese (Dvorak)", "kb_variants"), + tr("Japanese (Kana 86)", "kb_variants"), + tr("Japanese (Kana)", "kb_variants"), + tr("Japanese (Macintosh)", "kb_variants"), + tr("Japanese (OADG 109A)", "kb_variants"), + tr("Kabylian (Algeria, Tifinagh)", "kb_variants"), + tr("Kabylian (azerty layout, with dead keys)", "kb_variants"), + tr("Kabylian (qwerty-gb layout, with dead keys)", "kb_variants"), + tr("Kabylian (qwerty-us layout, with dead keys)", "kb_variants"), + tr("Kalmyk", "kb_variants"), + tr("Kannada", "kb_variants"), + tr("Kannada (KaGaPa, phonetic)", "kb_variants"), + tr("Kashubian", "kb_variants"), + tr("Kazakh (Latin)", "kb_variants"), + tr("Kazakh (extended)", "kb_variants"), + tr("Kazakh (with Russian)", "kb_variants"), + tr("Kikuyu", "kb_variants"), + tr("Komi", "kb_variants"), + tr("Korean (101/104-key compatible)", "kb_variants"), + tr("Kurdish (Iran, Arabic-Latin)", "kb_variants"), + tr("Kurdish (Iran, F)", "kb_variants"), + tr("Kurdish (Iran, Latin Alt-Q)", "kb_variants"), + tr("Kurdish (Iran, Latin Q)", "kb_variants"), + tr("Kurdish (Iraq, Arabic-Latin)", "kb_variants"), + tr("Kurdish (Iraq, F)", "kb_variants"), + tr("Kurdish (Iraq, Latin Alt-Q)", "kb_variants"), + tr("Kurdish (Iraq, Latin Q)", "kb_variants"), + tr("Kurdish (Syria, F)", "kb_variants"), + tr("Kurdish (Syria, Latin Alt-Q)", "kb_variants"), + tr("Kurdish (Syria, Latin Q)", "kb_variants"), + tr("Kurdish (Turkey, F)", "kb_variants"), + tr("Kurdish (Turkey, Latin Alt-Q)", "kb_variants"), + tr("Kurdish (Turkey, Latin Q)", "kb_variants"), + tr("Kyrgyz (phonetic)", "kb_variants"), + tr("Lao (STEA)", "kb_variants"), + tr("Latvian (F)", "kb_variants"), + tr("Latvian (adapted)", "kb_variants"), + tr("Latvian (apostrophe)", "kb_variants"), + tr("Latvian (ergonomic, ŪGJRMV)", "kb_variants"), + tr("Latvian (modern)", "kb_variants"), + tr("Latvian (tilde)", "kb_variants"), + tr("Lithuanian (IBM LST 1205-92)", "kb_variants"), + tr("Lithuanian (LEKP)", "kb_variants"), + tr("Lithuanian (LEKPa)", "kb_variants"), + tr("Lithuanian (US)", "kb_variants"), + tr("Lithuanian (standard)", "kb_variants"), + tr("Lower Sorbian", "kb_variants"), + tr("Lower Sorbian (QWERTZ)", "kb_variants"), + tr("Macedonian (no dead keys)", "kb_variants"), + tr("Malay (Jawi, phonetic)", "kb_variants"), + tr("Malayalam", "kb_variants"), + tr("Malayalam (Lalitha)", "kb_variants"), + tr("Malayalam (enhanced Inscript, with rupee)", "kb_variants"), + tr("Maltese (UK, with AltGr overrides)", "kb_variants"), + tr("Maltese (US layout with AltGr overrides)", "kb_variants"), + tr("Maltese (US)", "kb_variants"), + tr("Manipuri (Eeyek)", "kb_variants"), + tr("Marathi (KaGaPa, phonetic)", "kb_variants"), + tr("Marathi (enhanced Inscript)", "kb_variants"), + tr("Mari", "kb_variants"), + tr("Mmuock", "kb_variants"), + tr("Moldavian (Gagauz)", "kb_variants"), + tr("Mongolian (Bichig)", "kb_variants"), + tr("Mongolian (Galik)", "kb_variants"), + tr("Mongolian (Manchu Galik)", "kb_variants"), + tr("Mongolian (Manchu)", "kb_variants"), + tr("Mongolian (Todo Galik)", "kb_variants"), + tr("Mongolian (Todo)", "kb_variants"), + tr("Mongolian (Xibe)", "kb_variants"), + tr("Montenegrin (Cyrillic)", "kb_variants"), + tr("Montenegrin (Cyrillic, ZE and ZHE swapped)", "kb_variants"), + tr("Montenegrin (Cyrillic, with guillemets)", "kb_variants"), + tr("Montenegrin (Latin, QWERTY)", "kb_variants"), + tr("Montenegrin (Latin, Unicode)", "kb_variants"), + tr("Montenegrin (Latin, Unicode, QWERTY)", "kb_variants"), + tr("Montenegrin (Latin, with guillemets)", "kb_variants"), + tr("Northern Saami (Finland)", "kb_variants"), + tr("Northern Saami (Norway)", "kb_variants"), + tr("Northern Saami (Norway, no dead keys)", "kb_variants"), + tr("Northern Saami (Sweden)", "kb_variants"), + tr("Norwegian (Colemak)", "kb_variants"), + tr("Norwegian (Dvorak)", "kb_variants"), + tr("Norwegian (Macintosh)", "kb_variants"), + tr("Norwegian (Macintosh, no dead keys)", "kb_variants"), + tr("Norwegian (Windows)", "kb_variants"), + tr("Norwegian (no dead keys)", "kb_variants"), + tr("Occitan", "kb_variants"), + tr("Ogham", "kb_variants"), + tr("Ogham (IS434)", "kb_variants"), + tr("Ol Chiki", "kb_variants"), + tr("Oriya", "kb_variants"), + tr("Ossetian (Georgia)", "kb_variants"), + tr("Ossetian (Windows)", "kb_variants"), + tr("Ossetian (legacy)", "kb_variants"), + tr("Pannonian Rusyn", "kb_variants"), + tr("Pashto", "kb_variants"), + tr("Pashto (Afghanistan, OLPC)", "kb_variants"), + tr("Persian (Afghanistan, Dari OLPC)", "kb_variants"), + tr("Persian (with Persian keypad)", "kb_variants"), + tr("Polish (British keyboard)", "kb_variants"), + tr("Polish (Dvorak)", "kb_variants"), + tr("Polish (Dvorak, with Polish quotes on key 1)", "kb_variants"), + tr("Polish (Dvorak, with Polish quotes on quotemark key)", "kb_variants"), + tr("Polish (QWERTZ)", "kb_variants"), + tr("Polish (legacy)", "kb_variants"), + tr("Polish (programmer Dvorak)", "kb_variants"), + tr("Portuguese (Brazil, Dvorak)", "kb_variants"), + tr("Portuguese (Brazil, IBM/Lenovo ThinkPad)", "kb_variants"), + tr("Portuguese (Brazil, Nativo for US keyboards)", "kb_variants"), + tr("Portuguese (Brazil, Nativo)", "kb_variants"), + tr("Portuguese (Brazil, no dead keys)", "kb_variants"), + tr("Portuguese (Macintosh)", "kb_variants"), + tr("Portuguese (Macintosh, Sun dead keys)", "kb_variants"), + tr("Portuguese (Macintosh, no dead keys)", "kb_variants"), + tr("Portuguese (Nativo for US keyboards)", "kb_variants"), + tr("Portuguese (Nativo)", "kb_variants"), + tr("Portuguese (Sun dead keys)", "kb_variants"), + tr("Portuguese (no dead keys)", "kb_variants"), + tr("Punjabi (Gurmukhi Jhelum)", "kb_variants"), + tr("Punjabi (Gurmukhi)", "kb_variants"), + tr("Romanian (Germany)", "kb_variants"), + tr("Romanian (Germany, no dead keys)", "kb_variants"), + tr("Romanian (Windows)", "kb_variants"), + tr("Romanian (cedilla)", "kb_variants"), + tr("Romanian (standard cedilla)", "kb_variants"), + tr("Romanian (standard)", "kb_variants"), + tr("Russian (Belarus)", "kb_variants"), + tr("Russian (Czech, phonetic)", "kb_variants"), + tr("Russian (DOS)", "kb_variants"), + tr("Russian (Georgia)", "kb_variants"), + tr("Russian (Germany, phonetic)", "kb_variants"), + tr("Russian (Kazakhstan, with Kazakh)", "kb_variants"), + tr("Russian (Macintosh)", "kb_variants"), + tr("Russian (Poland, phonetic Dvorak)", "kb_variants"), + tr("Russian (Sweden, phonetic)", "kb_variants"), + tr("Russian (Sweden, phonetic, no dead keys)", "kb_variants"), + tr("Russian (US, phonetic)", "kb_variants"), + tr("Russian (Ukraine, standard RSTU)", "kb_variants"), + tr("Russian (legacy)", "kb_variants"), + tr("Russian (phonetic)", "kb_variants"), + tr("Russian (phonetic, AZERTY)", "kb_variants"), + tr("Russian (phonetic, Dvorak)", "kb_variants"), + tr("Russian (phonetic, French)", "kb_variants"), + tr("Russian (phonetic, Windows)", "kb_variants"), + tr("Russian (phonetic, YAZHERTY)", "kb_variants"), + tr("Russian (typewriter)", "kb_variants"), + tr("Russian (typewriter, legacy)", "kb_variants"), + tr("Saisiyat (Taiwan)", "kb_variants"), + tr("Samogitian", "kb_variants"), + tr("Sanskrit (KaGaPa, phonetic)", "kb_variants"), + tr("Serbian (Cyrillic, ZE and ZHE swapped)", "kb_variants"), + tr("Serbian (Cyrillic, with guillemets)", "kb_variants"), + tr("Serbian (Latin)", "kb_variants"), + tr("Serbian (Latin, QWERTY)", "kb_variants"), + tr("Serbian (Latin, Unicode)", "kb_variants"), + tr("Serbian (Latin, Unicode, QWERTY)", "kb_variants"), + tr("Serbian (Latin, with guillemets)", "kb_variants"), + tr("Serbian (Russia)", "kb_variants"), + tr("Serbo-Croatian (US)", "kb_variants"), + tr("Sicilian", "kb_variants"), + tr("Silesian", "kb_variants"), + tr("Sindhi", "kb_variants"), + tr("Sinhala (US)", "kb_variants"), + tr("Slovak (QWERTY)", "kb_variants"), + tr("Slovak (QWERTY, extended backslash)", "kb_variants"), + tr("Slovak (extended backslash)", "kb_variants"), + tr("Slovenian (US)", "kb_variants"), + tr("Slovenian (with guillemets)", "kb_variants"), + tr("Spanish (Dvorak)", "kb_variants"), + tr("Spanish (Latin American, Colemak for gaming)", "kb_variants"), + tr("Spanish (Latin American, Colemak)", "kb_variants"), + tr("Spanish (Latin American, Dvorak)", "kb_variants"), + tr("Spanish (Latin American, Sun dead keys)", "kb_variants"), + tr("Spanish (Latin American, dead tilde)", "kb_variants"), + tr("Spanish (Latin American, no dead keys)", "kb_variants"), + tr("Spanish (Macintosh)", "kb_variants"), + tr("Spanish (Sun dead keys)", "kb_variants"), + tr("Spanish (Windows)", "kb_variants"), + tr("Spanish (dead tilde)", "kb_variants"), + tr("Spanish (no dead keys)", "kb_variants"), + tr("Swedish (Dvorak)", "kb_variants"), + tr("Swedish (Dvorak, intl.)", "kb_variants"), + tr("Swedish (Macintosh)", "kb_variants"), + tr("Swedish (Svdvorak)", "kb_variants"), + tr("Swedish (US)", "kb_variants"), + tr("Swedish (no dead keys)", "kb_variants"), + tr("Swedish Sign Language", "kb_variants"), + tr("Syriac", "kb_variants"), + tr("Syriac (phonetic)", "kb_variants"), + tr("Taiwanese (indigenous)", "kb_variants"), + tr("Tajik (legacy)", "kb_variants"), + tr("Tamil (Inscript)", "kb_variants"), + tr("Tamil (Sri Lanka, TamilNet '99)", "kb_variants"), + tr("Tamil (Sri Lanka, TamilNet '99, TAB encoding)", "kb_variants"), + tr("Tamil (TamilNet '99 with Tamil numerals)", "kb_variants"), + tr("Tamil (TamilNet '99)", "kb_variants"), + tr("Tamil (TamilNet '99, TAB encoding)", "kb_variants"), + tr("Tamil (TamilNet '99, TSCII encoding)", "kb_variants"), + tr("Tatar", "kb_variants"), + tr("Telugu", "kb_variants"), + tr("Telugu (KaGaPa, phonetic)", "kb_variants"), + tr("Telugu (Sarala)", "kb_variants"), + tr("Thai (Pattachote)", "kb_variants"), + tr("Thai (TIS-820.2538)", "kb_variants"), + tr("Tibetan", "kb_variants"), + tr("Tibetan (with ASCII numerals)", "kb_variants"), + tr("Turkish (Alt-Q)", "kb_variants"), + tr("Turkish (F)", "kb_variants"), + tr("Turkish (Germany)", "kb_variants"), + tr("Turkish (Sun dead keys)", "kb_variants"), + tr("Turkish (intl., with dead keys)", "kb_variants"), + tr("Turkmen (Alt-Q)", "kb_variants"), + tr("Udmurt", "kb_variants"), + tr("Ukrainian (Windows)", "kb_variants"), + tr("Ukrainian (homophonic)", "kb_variants"), + tr("Ukrainian (legacy)", "kb_variants"), + tr("Ukrainian (phonetic)", "kb_variants"), + tr("Ukrainian (standard RSTU)", "kb_variants"), + tr("Ukrainian (typewriter)", "kb_variants"), + tr("Urdu (Pakistan, CRULP)", "kb_variants"), + tr("Urdu (Pakistan, NLA)", "kb_variants"), + tr("Urdu (Windows)", "kb_variants"), + tr("Urdu (alt. phonetic)", "kb_variants"), + tr("Urdu (phonetic)", "kb_variants"), + tr("Uyghur", "kb_variants"), + tr("Uzbek (Afghanistan)", "kb_variants"), + tr("Uzbek (Afghanistan, OLPC)", "kb_variants"), + tr("Uzbek (Latin)", "kb_variants"), + tr("Vietnamese (French)", "kb_variants"), + tr("Vietnamese (US)", "kb_variants"), + tr("Yakut", "kb_variants"), + tr("Yoruba", "kb_variants"), + QString() + }; +} +} + +/* This returns a reference to local, which is a terrible idea. + * Good thing it's not meant to be compiled. + */ +class kb_groups : public QObject { +Q_OBJECT +public: + const QStringList& table() + { + return QStringList { + tr("Alt+Caps Lock", "kb_group"), + tr("Alt+Ctrl", "kb_group"), + tr("Alt+Shift", "kb_group"), + tr("Alt+Space", "kb_group"), + tr("Any Win (while pressed)", "kb_group"), + tr("Both Alts together", "kb_group"), + tr("Both Alts together; AltGr alone chooses third level", "kb_group"), + tr("Both Ctrls together", "kb_group"), + tr("Both Shifts together", "kb_group"), + tr("Caps Lock", "kb_group"), + tr("Caps Lock (while pressed), Alt+Caps Lock for the original Caps Lock action", "kb_group"), + tr("Caps Lock to first layout; Shift+Caps Lock to second layout", "kb_group"), + tr("Ctrl+Left Win to first layout; Ctrl+Menu to second layout", "kb_group"), + tr("Ctrl+Shift", "kb_group"), + tr("Ctrl+Space", "kb_group"), + tr("Left Alt", "kb_group"), + tr("Left Alt (while pressed)", "kb_group"), + tr("Left Alt+Left Shift", "kb_group"), + tr("Left Ctrl", "kb_group"), + tr("Left Ctrl to first layout; Right Ctrl to second layout", "kb_group"), + tr("Left Ctrl+Left Shift", "kb_group"), + tr("Left Ctrl+Left Win", "kb_group"), + tr("Left Shift", "kb_group"), + tr("Left Win", "kb_group"), + tr("Left Win (while pressed)", "kb_group"), + tr("Left Win to first layout; Right Win/Menu to second layout", "kb_group"), + tr("Menu", "kb_group"), + tr("Menu (while pressed), Shift+Menu for Menu", "kb_group"), + tr("None", "kb_group"), + tr("Right Alt", "kb_group"), + tr("Right Alt (while pressed)", "kb_group"), + tr("Right Alt+Right Shift", "kb_group"), + tr("Right Ctrl", "kb_group"), + tr("Right Ctrl (while pressed)", "kb_group"), + tr("Right Ctrl+Right Shift", "kb_group"), + tr("Right Shift", "kb_group"), + tr("Right Win", "kb_group"), + tr("Right Win (while pressed)", "kb_group"), + tr("Scroll Lock", "kb_group"), + tr("Shift+Caps Lock", "kb_group"), + tr("Win+Space", "kb_group"), + QString() + }; +} +} + diff --git a/calamares/src/modules/keyboard/KeyboardLayoutModel.cpp b/calamares/src/modules/keyboard/KeyboardLayoutModel.cpp new file mode 100644 index 0000000..4163e74 --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardLayoutModel.cpp @@ -0,0 +1,287 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "KeyboardLayoutModel.h" + +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/Retranslator.h" + +#include + +#include + +static QTranslator* s_kbtranslator = nullptr; + +void +retranslateKeyboardModels() +{ + if ( !s_kbtranslator ) + { + s_kbtranslator = new QTranslator; + } + (void)Calamares::loadTranslator( Calamares::translatorLocaleName(), QStringLiteral( "kb_" ), s_kbtranslator ); +} + +XKBListModel::XKBListModel( QObject* parent ) + : QAbstractListModel( parent ) +{ +} + +int +XKBListModel::rowCount( const QModelIndex& ) const +{ + return m_list.count(); +} + +QVariant +XKBListModel::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() ) + { + return QVariant(); + } + if ( index.row() < 0 || index.row() >= m_list.count() ) + { + return QVariant(); + } + + const auto item = m_list.at( index.row() ); + switch ( role ) + { + case LabelRole: + if ( s_kbtranslator && !s_kbtranslator->isEmpty() && m_contextname ) + { + auto s = s_kbtranslator->translate( m_contextname, item.label.toUtf8().data() ); + if ( !s.isEmpty() ) + { + return s; + } + } + return item.label; + case KeyRole: + return item.key; + default: + return QVariant(); + } + __builtin_unreachable(); +} + +QString +XKBListModel::key( int index ) const +{ + if ( index < 0 || index >= m_list.count() ) + { + return QString(); + } + return m_list[ index ].key; +} + +QString +XKBListModel::label( int index ) const +{ + if ( index < 0 || index >= m_list.count() ) + { + return QString(); + } + return m_list[ index ].label; +} + +QHash< int, QByteArray > +XKBListModel::roleNames() const +{ + return { { Qt::DisplayRole, "label" }, { Qt::UserRole, "key" } }; +} + +void +XKBListModel::setCurrentIndex( int index ) +{ + if ( index >= m_list.count() || index < 0 ) + { + return; + } + if ( m_currentIndex != index ) + { + m_currentIndex = index; + emit currentIndexChanged( m_currentIndex ); + } +} + +KeyboardModelsModel::KeyboardModelsModel( QObject* parent ) + : XKBListModel( parent ) +{ + m_contextname = "kb_models"; + + // The models map is from human-readable names (!) to xkb identifier + const auto models = KeyboardGlobal::getKeyboardModels(); + m_list.reserve( models.count() ); + int index = 0; + for ( const auto& key : models.keys() ) + { + // So here *key* is the key in the map, which is the human-readable thing, + // while the struct fields are xkb-id, and human-readable + m_list << ModelInfo { models[ key ], key }; + if ( models[ key ] == "pc105" ) + { + m_defaultPC105 = index; + } + index++; + } + + cDebug() << "Loaded" << m_list.count() << "keyboard models"; + setCurrentIndex(); // If pc105 was seen, select it now +} + +KeyboardLayoutModel::KeyboardLayoutModel( QObject* parent ) + : QAbstractListModel( parent ) +{ + init(); +} + +int +KeyboardLayoutModel::rowCount( const QModelIndex& parent ) const +{ + Q_UNUSED( parent ) + return m_layouts.count(); +} + +QVariant +KeyboardLayoutModel::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() ) + { + return QVariant(); + } + + switch ( role ) + { + case Qt::DisplayRole: + { + auto description = m_layouts.at( index.row() ).second.description; + if ( s_kbtranslator && !s_kbtranslator->isEmpty() ) + { + auto s = s_kbtranslator->translate( "kb_layouts", description.toUtf8().data() ); + if ( !s.isEmpty() ) + { + return s; + } + } + return description; + } + case KeyboardVariantsRole: + return QVariant::fromValue( m_layouts.at( index.row() ).second.variants ); + case KeyboardLayoutKeyRole: + return m_layouts.at( index.row() ).first; + } + + return QVariant(); +} + +const QPair< QString, KeyboardGlobal::KeyboardInfo > +KeyboardLayoutModel::item( const int& index ) const +{ + if ( index >= m_layouts.count() || index < 0 ) + { + return QPair< QString, KeyboardGlobal::KeyboardInfo >(); + } + + return m_layouts.at( index ); +} + +QString +KeyboardLayoutModel::key( int index ) const +{ + if ( index >= m_layouts.count() || index < 0 ) + { + return QString(); + } + return m_layouts.at( index ).first; +} + +void +KeyboardLayoutModel::init() +{ + KeyboardGlobal::LayoutsMap layouts = KeyboardGlobal::getKeyboardLayouts(); + for ( KeyboardGlobal::LayoutsMap::const_iterator it = layouts.constBegin(); it != layouts.constEnd(); ++it ) + { + m_layouts.append( qMakePair( it.key(), it.value() ) ); + } + + std::stable_sort( m_layouts.begin(), + m_layouts.end(), + []( const QPair< QString, KeyboardGlobal::KeyboardInfo >& a, + const QPair< QString, KeyboardGlobal::KeyboardInfo >& b ) + { return a.second.description < b.second.description; } ); +} + +QHash< int, QByteArray > +KeyboardLayoutModel::roleNames() const +{ + return { { Qt::DisplayRole, "label" }, { KeyboardLayoutKeyRole, "key" }, { KeyboardVariantsRole, "variants" } }; +} + +void +KeyboardLayoutModel::setCurrentIndex( int index ) +{ + if ( index >= m_layouts.count() || index < 0 ) + { + return; + } + + if ( m_currentIndex != index ) + { + m_currentIndex = index; + emit currentIndexChanged( m_currentIndex ); + } +} + +int +KeyboardLayoutModel::currentIndex() const +{ + return m_currentIndex; +} + +KeyboardVariantsModel::KeyboardVariantsModel( QObject* parent ) + : XKBListModel( parent ) +{ + m_contextname = "kb_variants"; +} + +void +KeyboardVariantsModel::setVariants( QMap< QString, QString > variants ) +{ + beginResetModel(); + m_list.clear(); + m_list.reserve( variants.count() ); + for ( const auto& key : variants.keys() ) + { + m_list << ModelInfo { variants[ key ], key }; + } + m_currentIndex = -1; + endResetModel(); +} + +KeyboardGroupsSwitchersModel::KeyboardGroupsSwitchersModel( QObject* parent ) + : XKBListModel( parent ) +{ + m_contextname = "kb_groups"; + + // The groups map is from human-readable names (!) to xkb identifier + const auto groups = KeyboardGlobal::getKeyboardGroups(); + m_list.reserve( groups.count() ); + for ( const auto& key : groups.keys() ) + { + // So here *key* is the key in the map, which is the human-readable thing, + // while the struct fields are xkb-id, and human-readable + m_list << ModelInfo { groups[ key ], key }; + } + + cDebug() << "Loaded" << m_list.count() << "keyboard groups"; +} diff --git a/calamares/src/modules/keyboard/KeyboardLayoutModel.h b/calamares/src/modules/keyboard/KeyboardLayoutModel.h new file mode 100644 index 0000000..850f194 --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardLayoutModel.h @@ -0,0 +1,175 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARDLAYOUTMODEL_H +#define KEYBOARDLAYOUTMODEL_H + +#include "keyboardwidget/keyboardglobal.h" + +#include +#include +#include +#include + +/** @brief A list model with an xkb key and a human-readable string + * + * This model acts like it has a single selection, as well. + */ +class XKBListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY( int currentIndex WRITE setCurrentIndex READ currentIndex NOTIFY currentIndexChanged ) + +public: + enum + { + LabelRole = Qt::DisplayRole, ///< Human-readable + KeyRole = Qt::UserRole ///< xkb identifier + }; + + explicit XKBListModel( QObject* parent = nullptr ); + + int rowCount( const QModelIndex& = QModelIndex() ) const override; + QVariant data( const QModelIndex& index, int role ) const override; + /** @brief xkb key for a given index (row) + * + * This is like calling data( QModelIndex( index ), KeyRole ).toString(), + * but shorter and faster. Can return an empty string if index is invalid. + */ + QString key( int index ) const; + + /** @brief human-readable label for a given index (row) + * + * This is like calling data( QModelIndex( index ), LabelRole ).toString(), + * but shorter and faster. Can return an empty string if index is invalid. + */ + QString label( int index ) const; + + QHash< int, QByteArray > roleNames() const override; + + void setCurrentIndex( int index ); + int currentIndex() const { return m_currentIndex; } + +signals: + void currentIndexChanged( int index ); + +protected: + struct ModelInfo + { + /// XKB identifier + QString key; + /// Human-readable + QString label; + }; + QVector< ModelInfo > m_list; + int m_currentIndex = -1; + const char* m_contextname = nullptr; +}; + + +/** @brief A list model of the physical keyboard formats ("models" in xkb) + * + * This model acts like it has a single selection, as well. + */ +class KeyboardModelsModel : public XKBListModel +{ + Q_OBJECT + +public: + explicit KeyboardModelsModel( QObject* parent = nullptr ); + + /// @brief Set the index back to PC105 (the default physical model) + void setCurrentIndex() { XKBListModel::setCurrentIndex( m_defaultPC105 ); } + using XKBListModel::setCurrentIndex; + +private: + int m_defaultPC105 = -1; ///< The index of pc105, if there is one +}; + +/** @brief A list of keyboard layouts (arrangements of keycaps) + * + * Layouts can have a list of associated Variants, so this + * is slightly more complicated than the "regular" XKBListModel. + */ +class KeyboardLayoutModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY( int currentIndex WRITE setCurrentIndex READ currentIndex NOTIFY currentIndexChanged ) + +public: + enum Roles : int + { + KeyboardVariantsRole = Qt::UserRole, + KeyboardLayoutKeyRole + }; + + KeyboardLayoutModel( QObject* parent = nullptr ); + + int rowCount( const QModelIndex& parent = QModelIndex() ) const override; + + QVariant data( const QModelIndex& index, int role ) const override; + + void setCurrentIndex( int index ); + int currentIndex() const; + const QPair< QString, KeyboardGlobal::KeyboardInfo > item( const int& index ) const; + + /** @brief xkb key for a given index (row) + * + * This is like calling data( QModelIndex( index ), KeyboardLayoutKeyRole ).toString(), + * but shorter and faster. Can return an empty string if index is invalid. + */ + QString key( int index ) const; + +protected: + QHash< int, QByteArray > roleNames() const override; + +private: + void init(); + int m_currentIndex = -1; + QList< QPair< QString, KeyboardGlobal::KeyboardInfo > > m_layouts; + +signals: + void currentIndexChanged( int index ); +}; + +/** @brief A list of variants (xkb id and human-readable) + * + * The variants that are available depend on the Layout that is used, + * so the `setVariants()` function can be used to update the variants + * when the two models are related. + */ +class KeyboardVariantsModel : public XKBListModel +{ + Q_OBJECT + +public: + explicit KeyboardVariantsModel( QObject* parent = nullptr ); + + void setVariants( QMap< QString, QString > variants ); +}; + +/** @brief A list of groupsSwitcher (xkb id and human-readable) + * + * The list of group switching combinations `getKeyboardGroups()` + * function can be used to update the switching when the two models + * are related. + */ +class KeyboardGroupsSwitchersModel : public XKBListModel +{ + Q_OBJECT + +public: + explicit KeyboardGroupsSwitchersModel( QObject* parent = nullptr ); +}; + +/** @brief Adjust to changes in application language. + */ +void retranslateKeyboardModels(); + +#endif // KEYBOARDLAYOUTMODEL_H diff --git a/calamares/src/modules/keyboard/KeyboardPage.cpp b/calamares/src/modules/keyboard/KeyboardPage.cpp new file mode 100644 index 0000000..dbb80c6 --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardPage.cpp @@ -0,0 +1,139 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Portions from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "KeyboardPage.h" + +#include "Config.h" +#include "KeyboardLayoutModel.h" +#include "SetKeyboardLayoutJob.h" +#include "keyboardwidget/keyboardpreview.h" +#include "ui_KeyboardPage.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/String.h" + +#include +#include + +class LayoutItem : public QListWidgetItem +{ +public: + QString data; + + ~LayoutItem() override; +}; + +LayoutItem::~LayoutItem() {} + +KeyboardPage::KeyboardPage( Config* config, QWidget* parent ) + : QWidget( parent ) + , ui( new Ui::Page_Keyboard ) + , m_keyboardPreview( new KeyBoardPreview( this ) ) + , m_config( config ) +{ + ui->setupUi( this ); + + // Keyboard Preview + ui->KBPreviewLayout->addWidget( m_keyboardPreview ); + + { + auto* model = config->keyboardModels(); + model->setCurrentIndex(); // To default PC105 + ui->physicalModelSelector->setModel( model ); + ui->physicalModelSelector->setCurrentIndex( model->currentIndex() ); + } + { + auto* model = config->keyboardLayouts(); + ui->layoutSelector->setModel( model ); + ui->layoutSelector->setCurrentIndex( model->index( model->currentIndex() ) ); + } + { + auto* model = config->keyboardVariants(); + ui->variantSelector->setModel( model ); + ui->variantSelector->setCurrentIndex( model->index( model->currentIndex() ) ); + cDebug() << "Variants now total=" << model->rowCount() << "selected=" << model->currentIndex(); + } + { + auto* model = config->keyboardGroupsSwitchers(); + ui->groupSelector->setModel( model ); + ui->groupSelector->setCurrentIndex( model->currentIndex() ); + cDebug() << "Groups now total=" << model->rowCount() << "selected=" << model->currentIndex(); + } + + connect( ui->buttonRestore, + &QPushButton::clicked, + [ config = config ] { config->keyboardModels()->setCurrentIndex(); } ); + + connect( ui->physicalModelSelector, + QOverload< int >::of( &QComboBox::currentIndexChanged ), + config->keyboardModels(), + QOverload< int >::of( &XKBListModel::setCurrentIndex ) ); + connect( config->keyboardModels(), + &KeyboardModelsModel::currentIndexChanged, + ui->physicalModelSelector, + &QComboBox::setCurrentIndex ); + + connect( ui->layoutSelector->selectionModel(), + &QItemSelectionModel::currentChanged, + [ this ]( const QModelIndex& current ) + { m_config->keyboardLayouts()->setCurrentIndex( current.row() ); } ); + connect( config->keyboardLayouts(), + &KeyboardLayoutModel::currentIndexChanged, + [ this ]( int index ) + { + ui->layoutSelector->setCurrentIndex( m_config->keyboardLayouts()->index( index ) ); + m_keyboardPreview->setLayout( m_config->keyboardLayouts()->key( index ) ); + m_keyboardPreview->setVariant( + m_config->keyboardVariants()->key( m_config->keyboardVariants()->currentIndex() ) ); + } ); + + connect( ui->variantSelector->selectionModel(), + &QItemSelectionModel::currentChanged, + [ this ]( const QModelIndex& current ) + { m_config->keyboardVariants()->setCurrentIndex( current.row() ); } ); + connect( config->keyboardVariants(), + &KeyboardVariantsModel::currentIndexChanged, + [ this ]( int index ) + { + ui->variantSelector->setCurrentIndex( m_config->keyboardVariants()->index( index ) ); + m_keyboardPreview->setVariant( m_config->keyboardVariants()->key( index ) ); + } ); + + connect( ui->groupSelector, + QOverload< int >::of( &QComboBox::currentIndexChanged ), + config->keyboardGroupsSwitchers(), + QOverload< int >::of( &XKBListModel::setCurrentIndex ) ); + connect( config->keyboardGroupsSwitchers(), + &KeyboardGroupsSwitchersModel::currentIndexChanged, + ui->groupSelector, + &QComboBox::setCurrentIndex ); + + CALAMARES_RETRANSLATE_SLOT( &KeyboardPage::retranslate ); +} + +KeyboardPage::~KeyboardPage() +{ + delete ui; +} + +void +KeyboardPage::retranslate() +{ + ui->retranslateUi( this ); + m_config->retranslate(); +} diff --git a/calamares/src/modules/keyboard/KeyboardPage.h b/calamares/src/modules/keyboard/KeyboardPage.h new file mode 100644 index 0000000..f9870e1 --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardPage.h @@ -0,0 +1,48 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Portions from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARDPAGE_H +#define KEYBOARDPAGE_H + +#include "Job.h" + +#include +#include +#include + +namespace Ui +{ +class Page_Keyboard; +} // namespace Ui + +class Config; +class KeyBoardPreview; + +class KeyboardPage : public QWidget +{ + Q_OBJECT +public: + explicit KeyboardPage( Config* config, QWidget* parent = nullptr ); + ~KeyboardPage() override; + + void retranslate(); + +private: + Ui::Page_Keyboard* ui; + KeyBoardPreview* m_keyboardPreview; + Config* m_config; +}; + +#endif // KEYBOARDPAGE_H diff --git a/calamares/src/modules/keyboard/KeyboardPage.ui b/calamares/src/modules/keyboard/KeyboardPage.ui new file mode 100644 index 0000000..e131e5c --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardPage.ui @@ -0,0 +1,189 @@ + + + +SPDX-FileCopyrightText: 2014 Teo Mrnjavac <teo@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + Page_Keyboard + + + + 0 + 0 + 830 + 573 + + + + Form + + + + 9 + + + + + 0 + + + 12 + + + 12 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + 0 + + + + + Keyboard model: + + + + + + + + 0 + 0 + + + + + + + + + + + + :/images/restore.png:/images/restore.png + + + + 18 + 18 + + + + + + + + + + 9 + + + + + + + + + + + + + 0 + + + + + + 2 + 0 + + + + + 50 + false + + + + + + + + + + Type here to test your keyboard + + + + + + + Switch Keyboard: + + + + + + + + 1 + 0 + + + + + 0 + 0 + + + + + + + + + + physicalModelSelector + layoutSelector + variantSelector + groupSelector + LE_TestKeyboard + buttonRestore + + + + + + diff --git a/calamares/src/modules/keyboard/KeyboardViewStep.cpp b/calamares/src/modules/keyboard/KeyboardViewStep.cpp new file mode 100644 index 0000000..92df2d0 --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardViewStep.cpp @@ -0,0 +1,118 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "KeyboardViewStep.h" + +#include "Config.h" +#include "KeyboardPage.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( KeyboardViewStepFactory, registerPlugin< KeyboardViewStep >(); ) + +KeyboardViewStep::KeyboardViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_config( new Config( this ) ) + , m_widget( new KeyboardPage( m_config ) ) +{ + emit nextStatusChanged( true ); +} + + +KeyboardViewStep::~KeyboardViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +KeyboardViewStep::prettyName() const +{ + return tr( "Keyboard", "@label" ); +} + + +QString +KeyboardViewStep::prettyStatus() const +{ + return m_config->prettyStatus(); +} + + +QWidget* +KeyboardViewStep::widget() +{ + return m_widget; +} + + +bool +KeyboardViewStep::isNextEnabled() const +{ + return true; +} + + +bool +KeyboardViewStep::isBackEnabled() const +{ + return true; +} + + +bool +KeyboardViewStep::isAtBeginning() const +{ + return true; +} + + +bool +KeyboardViewStep::isAtEnd() const +{ + return true; +} + + +QList< Calamares::job_ptr > +KeyboardViewStep::jobs() const +{ + return m_config->createJobs(); +} + + +void +KeyboardViewStep::onActivate() +{ + m_config->guessLocaleKeyboardLayout(); +} + + +void +KeyboardViewStep::onLeave() +{ + m_config->finalize(); +} + +void +KeyboardViewStep::onCancel() +{ + m_config->cancel(); +} + +void +KeyboardViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); + m_config->detectCurrentKeyboardLayout(); +} diff --git a/calamares/src/modules/keyboard/KeyboardViewStep.h b/calamares/src/modules/keyboard/KeyboardViewStep.h new file mode 100644 index 0000000..96fd4c8 --- /dev/null +++ b/calamares/src/modules/keyboard/KeyboardViewStep.h @@ -0,0 +1,57 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARDVIEWSTEP_H +#define KEYBOARDVIEWSTEP_H + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include + +class Config; +class KeyboardPage; + +class PLUGINDLLEXPORT KeyboardViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit KeyboardViewStep( QObject* parent = nullptr ); + ~KeyboardViewStep() override; + + QString prettyName() const override; + QString prettyStatus() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onActivate() override; + void onLeave() override; + void onCancel() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + Config* m_config; + KeyboardPage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( KeyboardViewStepFactory ) + +#endif // KEYBOARDVIEWSTEP_H diff --git a/calamares/src/modules/keyboard/SetKeyboardLayoutJob.cpp b/calamares/src/modules/keyboard/SetKeyboardLayoutJob.cpp new file mode 100644 index 0000000..81eba13 --- /dev/null +++ b/calamares/src/modules/keyboard/SetKeyboardLayoutJob.cpp @@ -0,0 +1,423 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2011 Lennart Poettering + * SPDX-FileCopyrightText: Kay Sievers + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2014 Kevin Kofler + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Portions from systemd (localed.c): + * Copyright 2011 Lennart Poettering + * Copyright 2013 Kay Sievers + * (originally under LGPLv2.1+, used under the LGPL to GPL conversion clause) + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SetKeyboardLayoutJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/String.h" +#include "utils/System.h" + +#include +#include +#include +#include +#include + +namespace +{ +QStringList +removeEmpty( QStringList&& list ) +{ + list.removeAll( QString() ); + return list; +} +} // namespace + +SetKeyboardLayoutJob::SetKeyboardLayoutJob( const QString& model, + const QString& layout, + const QString& variant, + const AdditionalLayoutInfo& additionalLayoutInfo, + const QString& xOrgConfFileName, + const QString& convertedKeymapPath, + bool writeEtcDefaultKeyboard, + bool skipIfNoRoot ) + : Calamares::Job() + , m_model( model ) + , m_layout( layout ) + , m_variant( variant ) + , m_additionalLayoutInfo( additionalLayoutInfo ) + , m_xOrgConfFileName( xOrgConfFileName ) + , m_convertedKeymapPath( convertedKeymapPath ) + , m_writeEtcDefaultKeyboard( writeEtcDefaultKeyboard ) + , m_skipIfNoRoot( skipIfNoRoot ) +{ +} + + +QString +SetKeyboardLayoutJob::prettyName() const +{ + return tr( "Setting keyboard model to %1, layout as %2-%3…", "@status, %1 model, %2 layout, %3 variant" ) + .arg( m_model ) + .arg( m_layout ) + .arg( m_variant ); +} + + +QString +SetKeyboardLayoutJob::findConvertedKeymap( const QString& convertedKeymapPath ) const +{ + cDebug() << "Looking for converted keymap in" << convertedKeymapPath; + + // No search path supplied, assume the distribution does not provide + // converted keymaps + if ( convertedKeymapPath.isEmpty() ) + { + return QString(); + } + + QDir convertedKeymapDir( convertedKeymapPath ); + QString name = m_variant.isEmpty() ? m_layout : ( m_layout + '-' + m_variant ); + + if ( convertedKeymapDir.exists( name + ".map" ) || convertedKeymapDir.exists( name + ".map.gz" ) ) + { + cDebug() << Logger::SubEntry << "Found converted keymap" << name; + return name; + } + + return QString(); +} + + +STATICTEST QString +findLegacyKeymap( const QString& layout, const QString& model, const QString& variant ) +{ + cDebug() << "Looking for legacy keymap" << layout << model << variant << "in QRC"; + + int bestMatching = 0; + QString name; + + QFile file( ":/kbd-model-map" ); + if ( !file.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + cDebug() << Logger::SubEntry << "Could not read QRC"; + return QString(); + } + + QTextStream stream( &file ); + while ( !stream.atEnd() ) + { + QString line = stream.readLine().trimmed(); + if ( line.isEmpty() || line.startsWith( '#' ) ) + { + continue; + } + + QStringList mapping = line.split( '\t', SplitSkipEmptyParts ); + if ( mapping.size() < 5 ) + { + continue; + } + + int matching = 0; + + // Determine how well matching this entry is + // We assume here that we have one X11 layout. If the UI changes to + // allow more than one layout, this should change too. + if ( layout == mapping[ 1 ] ) + // If we got an exact match, this is best + { + matching = 10; + } + // Look for an entry whose first layout matches ours + else if ( mapping[ 1 ].startsWith( layout + ',' ) ) + { + matching = 5; + } + + if ( matching > 0 ) + { + if ( model.isEmpty() || model == mapping[ 2 ] ) + { + matching++; + } + + QString mappingVariant = mapping[ 3 ]; + if ( mappingVariant == "-" ) + { + mappingVariant = QString(); + } + else if ( mappingVariant.startsWith( ',' ) ) + { + mappingVariant.remove( 1, 0 ); + } + + if ( variant == mappingVariant ) + { + matching++; + } + + // We ignore mapping[4], the xkb options, for now. If we ever + // allow setting options in the UI, we should match them here. + } + + // The best matching entry so far, then let's save that + if ( matching >= qMax( bestMatching, 1 ) ) + { + cDebug() << Logger::SubEntry << "Found legacy keymap" << mapping[ 0 ] << "with score" << matching; + + if ( matching > bestMatching ) + { + bestMatching = matching; + name = mapping[ 0 ]; + } + } + } + + return name; +} + +QString +SetKeyboardLayoutJob::findLegacyKeymap() const +{ + return ::findLegacyKeymap( m_layout, m_model, m_variant ); +} + +bool +SetKeyboardLayoutJob::writeVConsoleData( const QString& vconsoleConfPath, const QString& convertedKeymapPath ) const +{ + cDebug() << "Writing vconsole data to" << vconsoleConfPath; + + QString keymap = findConvertedKeymap( convertedKeymapPath ); + if ( keymap.isEmpty() ) + { + keymap = findLegacyKeymap(); + } + if ( keymap.isEmpty() ) + { + cDebug() << "Trying to use X11 layout" << m_layout << "as the virtual console layout"; + keymap = m_layout; + } + + QStringList existingLines; + + // Read in the existing vconsole.conf, if it exists + QFile file( vconsoleConfPath ); + if ( file.exists() ) + { + file.open( QIODevice::ReadOnly | QIODevice::Text ); + QTextStream stream( &file ); + while ( !stream.atEnd() ) + { + existingLines << stream.readLine(); + } + file.close(); + if ( stream.status() != QTextStream::Ok ) + { + cError() << "Could not read lines from" << file.fileName(); + return false; + } + } + + // Write out the existing lines and replace the KEYMAP= line + if ( !file.open( QIODevice::WriteOnly | QIODevice::Text ) ) + { + cError() << "Could not open" << file.fileName() << "for writing."; + return false; + } + QTextStream stream( &file ); + bool found = false; + for ( const QString& existingLine : std::as_const( existingLines ) ) + { + if ( existingLine.trimmed().startsWith( "KEYMAP=" ) ) + { + stream << "KEYMAP=" << keymap << '\n'; + found = true; + } + else + { + stream << existingLine << '\n'; + } + } + // Add a KEYMAP= line if there wasn't any + if ( !found ) + { + stream << "KEYMAP=" << keymap << '\n'; + } + stream.flush(); + file.close(); + + cDebug() << Logger::SubEntry << "Written KEYMAP=" << keymap << "to vconsole.conf" << stream.status(); + + return ( stream.status() == QTextStream::Ok ); +} + + +bool +SetKeyboardLayoutJob::writeX11Data( const QString& keyboardConfPath ) const +{ + cDebug() << "Writing X11 configuration to" << keyboardConfPath; + + QFile file( keyboardConfPath ); + if ( !file.open( QIODevice::WriteOnly | QIODevice::Text ) ) + { + cError() << "Could not open" << file.fileName() << "for writing."; + return false; + } + QTextStream stream( &file ); + + stream << "# Read and parsed by systemd-localed. It's probably wise not to edit this file\n" + "# manually too freely.\n" + "Section \"InputClass\"\n" + " Identifier \"system-keyboard\"\n" + " MatchIsKeyboard \"on\"\n"; + + + const QStringList layouts = removeEmpty( { m_additionalLayoutInfo.additionalLayout, m_layout } ); + const QStringList variants = removeEmpty( { m_additionalLayoutInfo.additionalVariant, m_variant } ); + stream << " Option \"XkbLayout\" \"" << layouts.join( "," ) << "\"\n"; + stream << " Option \"XkbVariant\" \"" << variants.join( "," ) << "\"\n"; + if ( !m_additionalLayoutInfo.additionalLayout.isEmpty() ) + { + stream << " Option \"XkbOptions\" \"" << m_additionalLayoutInfo.groupSwitcher << "\"\n"; + } + + stream << "EndSection\n"; + stream.flush(); + + file.close(); + + cDebug() << Logger::SubEntry << "Written XkbLayout" << layouts.join( "," ) << "; XkbModel" << m_model + << "; XkbVariant" << variants.join( "," ) << "to X.org file" << keyboardConfPath << stream.status(); + + return ( stream.status() == QTextStream::Ok ); +} + + +bool +SetKeyboardLayoutJob::writeDefaultKeyboardData( const QString& defaultKeyboardPath ) const +{ + cDebug() << "Writing default keyboard data to" << defaultKeyboardPath; + + QFile file( defaultKeyboardPath ); + if ( !file.open( QIODevice::WriteOnly | QIODevice::Text ) ) + { + cError() << "Could not open" << defaultKeyboardPath << "for writing"; + return false; + } + QTextStream stream( &file ); + + const QStringList layouts = removeEmpty( { m_additionalLayoutInfo.additionalLayout, m_layout } ); + const QStringList variants = removeEmpty( { m_additionalLayoutInfo.additionalVariant, m_variant } ); + stream << "# KEYBOARD CONFIGURATION FILE\n\n" + "# Consult the keyboard(5) manual page.\n\n"; + + stream << "XKBMODEL=\"" << m_model << "\"\n"; + stream << "XKBLAYOUT=\"" << layouts.join( "," ) << "\"\n"; + stream << "XKBVARIANT=\"" << variants.join( "," ) << "\"\n"; + if ( !m_additionalLayoutInfo.additionalLayout.isEmpty() ) + { + stream << "XKBOPTIONS=\"" << m_additionalLayoutInfo.groupSwitcher << "\"\n"; + } + stream << "BACKSPACE=\"guess\"\n"; + stream.flush(); + + file.close(); + + cDebug() << Logger::SubEntry << "Written XKBMODEL" << m_model << "; XKBLAYOUT" << layouts.join( "," ) + << "; XKBVARIANT" << variants.join( "," ) << "to /etc/default/keyboard file" << defaultKeyboardPath + << stream.status(); + + return ( stream.status() == QTextStream::Ok ); +} + + +Calamares::JobResult +SetKeyboardLayoutJob::exec() +{ + cDebug() << "Executing SetKeyboardLayoutJob"; + // Read the location of the destination's / in the host file system from + // the global settings + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + QDir destDir( gs->value( "rootMountPoint" ).toString() ); + + // Skip this if we are using locale1 and we are configuring the local system, + // since the service will have already updated these configs for us. + if ( !( m_skipIfNoRoot && ( destDir.isEmpty() || destDir.isRoot() ) ) ) + { + // Get the path to the destination's /etc/vconsole.conf + QString vconsoleConfPath = destDir.absoluteFilePath( "etc/vconsole.conf" ); + + // Get the path to the destination's path to the converted key mappings + QString convertedKeymapPath = m_convertedKeymapPath; + if ( !convertedKeymapPath.isEmpty() ) + { + while ( convertedKeymapPath.startsWith( '/' ) ) + { + convertedKeymapPath.remove( 0, 1 ); + } + convertedKeymapPath = destDir.absoluteFilePath( convertedKeymapPath ); + } + + if ( !writeVConsoleData( vconsoleConfPath, convertedKeymapPath ) ) + { + return Calamares::JobResult::error( + tr( "Failed to write keyboard configuration for the virtual console.", "@error" ), + tr( "Failed to write to %1", "@error, %1 is virtual console configuration path" ) + .arg( vconsoleConfPath ) ); + } + + // Get the path to the destination's /etc/X11/xorg.conf.d/00-keyboard.conf + QString xorgConfDPath; + QString keyboardConfPath; + if ( QDir::isAbsolutePath( m_xOrgConfFileName ) ) + { + keyboardConfPath = m_xOrgConfFileName; + while ( keyboardConfPath.startsWith( '/' ) ) + { + keyboardConfPath.remove( 0, 1 ); + } + keyboardConfPath = destDir.absoluteFilePath( keyboardConfPath ); + xorgConfDPath = QFileInfo( keyboardConfPath ).path(); + } + else + { + xorgConfDPath = destDir.absoluteFilePath( "etc/X11/xorg.conf.d" ); + keyboardConfPath = QDir( xorgConfDPath ).absoluteFilePath( m_xOrgConfFileName ); + } + destDir.mkpath( xorgConfDPath ); + + if ( !writeX11Data( keyboardConfPath ) ) + { + return Calamares::JobResult::error( + tr( "Failed to write keyboard configuration for X11.", "@error" ), + tr( "Failed to write to %1", "@error, %1 is keyboard configuration path" ).arg( keyboardConfPath ) ); + } + } + + { + QString defaultKeyboardPath; + if ( QDir( destDir.absoluteFilePath( "etc/default" ) ).exists() ) + { + defaultKeyboardPath = destDir.absoluteFilePath( "etc/default/keyboard" ); + } + + if ( !defaultKeyboardPath.isEmpty() && m_writeEtcDefaultKeyboard ) + { + if ( !writeDefaultKeyboardData( defaultKeyboardPath ) ) + { + return Calamares::JobResult::error( + tr( "Failed to write keyboard configuration to existing /etc/default directory.", "@error" ), + tr( "Failed to write to %1", "@error, %1 is default keyboard path" ).arg( defaultKeyboardPath ) ); + } + } + } + + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/keyboard/SetKeyboardLayoutJob.h b/calamares/src/modules/keyboard/SetKeyboardLayoutJob.h new file mode 100644 index 0000000..87aa8ff --- /dev/null +++ b/calamares/src/modules/keyboard/SetKeyboardLayoutJob.h @@ -0,0 +1,51 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2014 Kevin Kofler + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SETKEYBOARDLAYOUTJOB_H +#define SETKEYBOARDLAYOUTJOB_H + +#include "AdditionalLayoutInfo.h" +#include "Job.h" + + +class SetKeyboardLayoutJob : public Calamares::Job +{ + Q_OBJECT +public: + SetKeyboardLayoutJob( const QString& model, + const QString& layout, + const QString& variant, + const AdditionalLayoutInfo& additionaLayoutInfo, + const QString& xOrgConfFileName, + const QString& convertedKeymapPath, + bool writeEtcDefaultKeyboard, + bool skipIfNoRoot ); + + QString prettyName() const override; + Calamares::JobResult exec() override; + +private: + QString findConvertedKeymap( const QString& convertedKeymapPath ) const; + QString findLegacyKeymap() const; + bool writeVConsoleData( const QString& vconsoleConfPath, const QString& convertedKeymapPath ) const; + bool writeX11Data( const QString& keyboardConfPath ) const; + bool writeDefaultKeyboardData( const QString& defaultKeyboardPath ) const; + + QString m_model; + QString m_layout; + QString m_variant; + AdditionalLayoutInfo m_additionalLayoutInfo; + QString m_xOrgConfFileName; + QString m_convertedKeymapPath; + const bool m_writeEtcDefaultKeyboard; + const bool m_skipIfNoRoot; +}; + +#endif /* SETKEYBOARDLAYOUTJOB_H */ diff --git a/calamares/src/modules/keyboard/Tests.cpp b/calamares/src/modules/keyboard/Tests.cpp new file mode 100644 index 0000000..2cdb589 --- /dev/null +++ b/calamares/src/modules/keyboard/Tests.cpp @@ -0,0 +1,68 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "utils/Logger.h" + +#include + +// Internals of SetKeyboardLayoutJob.cpp +extern QString findLegacyKeymap( const QString& layout, const QString& model, const QString& variant ); + +class KeyboardLayoutTests : public QObject +{ + Q_OBJECT +public: + KeyboardLayoutTests() {} + ~KeyboardLayoutTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testSimpleLayoutLookup_data(); + void testSimpleLayoutLookup(); +}; + +void +KeyboardLayoutTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +KeyboardLayoutTests::testSimpleLayoutLookup_data() +{ + QTest::addColumn< QString >( "layout" ); + QTest::addColumn< QString >( "model" ); + QTest::addColumn< QString >( "variant" ); + QTest::addColumn< QString >( "vconsole" ); + + QTest::newRow( "us" ) << QString( "us" ) << QString() << QString() << QString( "us" ); + QTest::newRow( "turkish default" ) << QString( "tr" ) << QString() << QString() << QString( "trq" ); + QTest::newRow( "turkish alt-q" ) << QString( "tr" ) << QString() << QString( "alt" ) << QString( "trq" ); + QTest::newRow( "turkish f" ) << QString( "tr" ) << QString() << QString( "f" ) << QString( "trf" ); + QTest::newRow( "italian" ) << QString( "it" ) << QString( "pc105" ) << QString() << QString( "it" ); +} + + +void +KeyboardLayoutTests::testSimpleLayoutLookup() +{ + QFETCH( QString, layout ); + QFETCH( QString, model ); + QFETCH( QString, variant ); + QFETCH( QString, vconsole ); + + QCOMPARE( findLegacyKeymap( layout, model, variant ), vconsole ); +} + + +QTEST_GUILESS_MAIN( KeyboardLayoutTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/keyboard/images/restore.png b/calamares/src/modules/keyboard/images/restore.png new file mode 100644 index 0000000000000000000000000000000000000000..094c2c174bde620936e6258e4af1f237a97efc31 GIT binary patch literal 876 zcmV-y1C#uTP);W* zRLobvV%E10pq?c_d=>DkvNb6nyITQ@<7Fi1Yv2=^Ndet0RRQBrq~vUaTSWnh zfb6dnvp;YIl#H`?*VzwL4^21%I>jnTiKQa}Q^f@IM9H58AVAUX)IsT&jL$cW5zsIm zQd^YZGp^|LM8Tg0_#HMt#Y`P`8@pV^Zx*1J +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboard/kbd-model-map b/calamares/src/modules/keyboard/kbd-model-map new file mode 100644 index 0000000..ab024f0 --- /dev/null +++ b/calamares/src/modules/keyboard/kbd-model-map @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: 2015 Systemd authors and contributors +# SPDX-FileCopyrightText: 2018 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copied from systemd-localed +# +# https://cgit.freedesktop.org/systemd/systemd/log/src/locale/kbd-model-map +# (originally under LGPLv2.1+, used under the LGPL to GPL conversion clause) +# +# This is the version from 534644b7be7b240eb0fbbe06e20cbecbe8206767, +# committed 2015-01-22 01:07:24 . +# +# Updates: +# - 2018-09-26 Added "Austrian" keyboard (de at). Issue #1035 +# - 2020-09-09 Added "Turkish F" keyboard. Issue #1397 +# +# Note that keyboard variants should be listed from least to most-specific +# within a layout. Keyboard lookups only consider a subsequent +# line if it has a strictly better match than previous ones: +# listing specific variants early can mean a poor match with them +# is not overridden by a poor match with a later generic variant. +# +# Generated from system-config-keyboard's model list +# consolelayout xlayout xmodel xvariant xoptions +sg ch pc105 de_nodeadkeys terminate:ctrl_alt_bksp +nl nl pc105 - terminate:ctrl_alt_bksp +mk-utf mk,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +trq tr pc105 - terminate:ctrl_alt_bksp +trf tr pc105 f terminate:ctrl_alt_bksp +uk gb pc105 - terminate:ctrl_alt_bksp +is-latin1 is pc105 - terminate:ctrl_alt_bksp +de de pc105 - terminate:ctrl_alt_bksp +de at pc105 - terminate:ctrl_alt_bksp +la-latin1 latam pc105 - terminate:ctrl_alt_bksp +us us pc105+inet - terminate:ctrl_alt_bksp +ko kr pc105 - terminate:ctrl_alt_bksp +ro-std ro pc105 std terminate:ctrl_alt_bksp +de-latin1 de pc105 - terminate:ctrl_alt_bksp +slovene si pc105 - terminate:ctrl_alt_bksp +hu101 hu pc105 qwerty terminate:ctrl_alt_bksp +jp106 jp jp106 - terminate:ctrl_alt_bksp +croat hr pc105 - terminate:ctrl_alt_bksp +it it pc105 - terminate:ctrl_alt_bksp +it2 it pc105 - terminate:ctrl_alt_bksp +hu hu pc105 - terminate:ctrl_alt_bksp +sr-latin rs pc105 latin terminate:ctrl_alt_bksp +fi fi pc105 - terminate:ctrl_alt_bksp +fr_CH ch pc105 fr terminate:ctrl_alt_bksp +dk-latin1 dk pc105 - terminate:ctrl_alt_bksp +fr fr pc105 - terminate:ctrl_alt_bksp +ua-utf ua,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +fr-latin1 fr pc105 - terminate:ctrl_alt_bksp +sg-latin1 ch pc105 de_nodeadkeys terminate:ctrl_alt_bksp +be-latin1 be pc105 - terminate:ctrl_alt_bksp +dk dk pc105 - terminate:ctrl_alt_bksp +fr-pc fr pc105 - terminate:ctrl_alt_bksp +bg_pho-utf8 bg,us pc105 ,phonetic terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +it-ibm it pc105 - terminate:ctrl_alt_bksp +cz-us-qwertz cz,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +br-abnt2 br abnt2 - terminate:ctrl_alt_bksp +ro ro pc105 - terminate:ctrl_alt_bksp +us-acentos us pc105 intl terminate:ctrl_alt_bksp +pt-latin1 pt pc105 - terminate:ctrl_alt_bksp +ro-std-cedilla ro pc105 std_cedilla terminate:ctrl_alt_bksp +tj_alt-UTF8 tj pc105 - terminate:ctrl_alt_bksp +de-latin1-nodeadkeys de pc105 nodeadkeys terminate:ctrl_alt_bksp +no no pc105 - terminate:ctrl_alt_bksp +bg_bds-utf8 bg,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +dvorak us pc105 dvorak terminate:ctrl_alt_bksp +dvorak us pc105 dvorak-alt-intl terminate:ctrl_alt_bksp +ru ru,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +cz-lat2 cz pc105 qwerty terminate:ctrl_alt_bksp +pl2 pl pc105 - terminate:ctrl_alt_bksp +es es pc105 - terminate:ctrl_alt_bksp +ro-cedilla ro pc105 cedilla terminate:ctrl_alt_bksp +ie ie pc105 - terminate:ctrl_alt_bksp +et ee pc105 - terminate:ctrl_alt_bksp +sk-qwerty sk pc105 - terminate:ctrl_alt_bksp,qwerty +sk-qwertz sk pc105 - terminate:ctrl_alt_bksp +fr-latin9 fr pc105 latin9 terminate:ctrl_alt_bksp +fr_CH-latin1 ch pc105 fr terminate:ctrl_alt_bksp +cf ca pc105 - terminate:ctrl_alt_bksp +sv-latin1 se pc105 - terminate:ctrl_alt_bksp +sr-cy rs pc105 - terminate:ctrl_alt_bksp +gr gr,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +by by,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +il il pc105 - terminate:ctrl_alt_bksp +kazakh kz,us pc105 - terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll +lt.baltic lt pc105 - terminate:ctrl_alt_bksp +lt.l4 lt pc105 - terminate:ctrl_alt_bksp +lt lt pc105 - terminate:ctrl_alt_bksp +khmer kh,us pc105 - terminate:ctrl_alt_bksp diff --git a/calamares/src/modules/keyboard/keyboard.conf b/calamares/src/modules/keyboard/keyboard.conf new file mode 100644 index 0000000..b4850b2 --- /dev/null +++ b/calamares/src/modules/keyboard/keyboard.conf @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# NOTE: you must have ckbcomp installed and runnable +# on the live system, for keyboard layout previews. +--- +# The name of the file to write X11 keyboard settings to +# The default value is the name used by upstream systemd-localed. +# Relative paths are assumed to be relative to /etc/X11/xorg.conf.d +xOrgConfFileName: "/etc/X11/xorg.conf.d/00-keyboard.conf" + +# The path to search for keymaps converted from X11 to kbd format. +# Common paths for this are: +# - /lib/kbd/keymaps/xkb +# - /usr/share/kbd/keymaps/xkb +# Leave this empty if the setting does not make sense on your distribution. +# +convertedKeymapPath: "/lib/kbd/keymaps/xkb" + +# Write keymap configuration to /etc/default/keyboard, usually +# found on Debian-related systems. +# Defaults to true if nothing is set. +#writeEtcDefaultKeyboard: true + +# Use the Locale1 service instead of directly managing configuration files. +# This is the modern mechanism for configuring the systemwide keyboard layout, +# and works on Wayland compositors to set the current layout. +# Defaults to false on X11 and true otherwise. +#useLocale1: true + +# Guess the default layout from the user locale. If false, keeps the current +# OS keyboard layout as the default (useful if the layout is pre-configured). +#guessLayout: true + +# Things that should be configured. +configure: + # Configure KWin (KDE Plasma) directly by editing the + # configuration file and informing KWin over DBus. This is + # useful in a system that uses Wayland but does **not** connect + # locale1 with KWin. + # + # Systems that use KDE Plasma Wayland and locale1 can instead start the + # compositor KWin with command-line argument `--locale1`. That + # argument makes this configuration option unnecessary. + kwin: false + # Configure keyboard when using Wayland with Gnome on Ubuntu 24.10+ + gnome: false diff --git a/calamares/src/modules/keyboard/keyboard.qrc b/calamares/src/modules/keyboard/keyboard.qrc new file mode 100644 index 0000000..4283d81 --- /dev/null +++ b/calamares/src/modules/keyboard/keyboard.qrc @@ -0,0 +1,7 @@ + + + kbd-model-map + images/restore.png + non-ascii-layouts + + diff --git a/calamares/src/modules/keyboard/keyboard.schema.yaml b/calamares/src/modules/keyboard/keyboard.schema.yaml new file mode 100644 index 0000000..0fb1fdc --- /dev/null +++ b/calamares/src/modules/keyboard/keyboard.schema.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/finished +additionalProperties: false +type: object +properties: + xOrgConfFileName: { type: string } + convertedKeymapPath: { type: string } + writeEtcDefaultKeyboard: { type: boolean, default: true } + useLocale1: { type: boolean, default: false } + guessLayout: { type: boolean, default: true } + configure: + additionalProperties: false + type: object + properties: + kwin: { type: boolean, default: false } +required: [ xOrgConfFileName, convertedKeymapPath ] diff --git a/calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.cpp b/calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.cpp new file mode 100644 index 0000000..83b8d82 --- /dev/null +++ b/calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.cpp @@ -0,0 +1,264 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Source by Georg Grabler + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "keyboardglobal.h" + +#include "utils/Logger.h" + +#include +#include +#include + +#ifdef Q_OS_FREEBSD +static const char XKB_FILE[] = "/usr/local/share/X11/xkb/rules/base.lst"; +#else +static const char XKB_FILE[] = "/usr/share/X11/xkb/rules/base.lst"; +#endif + +// The xkb rules file is made of several "sections". Each section +// starts with a line "! ". The static methods here +// handle individual sections. + +/** @brief Scans a file for a named section + * + * Reads from @p fh incrementally until it finds a section named @p name + * or hits end-of-file. Returns true if the section is found. The + * @p name must include the "! " section marker as well. + */ +static bool +findSection( QFile& fh, const char* name ) +{ + while ( !fh.atEnd() ) + { + QByteArray line = fh.readLine(); + if ( line.startsWith( name ) ) + { + return true; + } + } + return false; +} + +static KeyboardGlobal::ModelsMap +parseKeyboardModels( const char* filepath ) +{ + KeyboardGlobal::ModelsMap models; + + QFile fh( filepath ); + fh.open( QIODevice::ReadOnly ); + + if ( !fh.isOpen() ) + { + cDebug() << "X11 Keyboard model definitions not found!"; + return models; + } + + bool modelsFound = findSection( fh, "! model" ); + // read the file until the end or until we break the loop + while ( modelsFound && !fh.atEnd() ) + { + QByteArray line = fh.readLine(); + + // check if we start a new section + if ( line.startsWith( '!' ) ) + { + break; + } + + // Here we are in the model section, otherwise we would continue or break. + // Sample model lines: + // + // ! model + // pc86 Generic 86-key PC + // pc101 Generic 101-key PC + // + QRegularExpression rx( "^\\s+(\\S+)\\s+(\\w.*)\n$" ); + QRegularExpressionMatch m; + + // insert into the model map + if ( QString( line ).indexOf( rx, 0, &m ) != -1 ) + { + const QString modelDescription = m.captured( 2 ); + const QString model = m.captured( 1 ); + models.insert( modelDescription, model ); + } + } + + return models; +} + + +KeyboardGlobal::LayoutsMap +parseKeyboardLayouts( const char* filepath ) +{ + KeyboardGlobal::LayoutsMap layouts; + + //### Get Layouts ###// + + QFile fh( filepath ); + fh.open( QIODevice::ReadOnly ); + + if ( !fh.isOpen() ) + { + cDebug() << "X11 Keyboard layout definitions not found!"; + return layouts; + } + + bool layoutsFound = findSection( fh, "! layout" ); + // read the file until the end or we break the loop + while ( layoutsFound && !fh.atEnd() ) + { + QByteArray line = fh.readLine(); + + if ( line.startsWith( '!' ) ) + { + break; + } + + // Sample layout lines: + // + // ! layout + // us English (US) + // af Afghani + QRegularExpression rx( "^\\s+(\\S+)\\s+(\\w.*)\n$" ); + QRegularExpressionMatch m; + + // insert into the layout map + if ( QString( line ).indexOf( rx, 0, &m ) != -1 ) + { + KeyboardGlobal::KeyboardInfo info; + info.description = m.captured( 2 ); + info.variants.insert( QObject::tr( "Default" ), "" ); + layouts.insert( m.captured( 1 ), info ); + } + } + + fh.reset(); + + + //### Get Variants ###// + + bool variantsFound = findSection( fh, "! variant" ); + // read the file until the end or until we break + while ( variantsFound && !fh.atEnd() ) + { + QByteArray line = fh.readLine(); + + if ( line.startsWith( '!' ) ) + { + break; + } + + // Sample variant lines: + // + // ! variant + // chr us: Cherokee + // haw us: Hawaiian + // ps af: Pashto + // uz af: Uzbek (Afghanistan) + QRegularExpression rx( "^\\s+(\\S+)\\s+(\\S+): (\\w.*)\n$" ); + QRegularExpressionMatch m; + + // insert into the variants multimap, if the pattern matches + if ( QString( line ).indexOf( rx, 0, &m ) != -1 ) + { + const QString variantKey = m.captured( 1 ); + const QString baseLayout = m.captured( 2 ); + const QString description = m.captured( 3 ); + if ( layouts.find( baseLayout ) != layouts.end() ) + { + // in this case we found an entry in the multimap, and add the values to the multimap + layouts.find( baseLayout ).value().variants.insert( description, variantKey ); + } + else + { + // create a new map in the multimap - the value was not found. + KeyboardGlobal::KeyboardInfo info; + info.description = baseLayout; + info.variants.insert( QObject::tr( "Default" ), "" ); + info.variants.insert( description, variantKey ); + layouts.insert( baseLayout, info ); + } + } + } + + return layouts; +} + +static KeyboardGlobal::GroupsMap +parseKeyboardGroupsSwitchers( const char* filepath ) +{ + KeyboardGlobal::GroupsMap models; + + QFile fh( filepath ); + fh.open( QIODevice::ReadOnly ); + + if ( !fh.isOpen() ) + { + cDebug() << "X11 Keyboard model definitions not found!"; + return models; + } + + QRegularExpression rx; + rx.setPattern( "^\\s+grp:(\\S+)\\s+(\\w.*)\n$" ); + + bool optionSectionFound = findSection( fh, "! option" ); + // read the file until the end or until we break the loop + while ( optionSectionFound && !fh.atEnd() ) + { + QByteArray line = fh.readLine(); + + // check if we start a new section + if ( line.startsWith( '!' ) ) + { + break; + } + + // here we are in the option section - find all "grp:" options + + // insert into the model map + QRegularExpressionMatch match = rx.match( line ); + if ( match.hasMatch() ) + { + QString modelDesc = match.captured( 2 ); + QString model = match.captured( 1 ); + models.insert( modelDesc, model ); + } + } + + return models; +} + + +KeyboardGlobal::LayoutsMap +KeyboardGlobal::getKeyboardLayouts() +{ + return parseKeyboardLayouts( XKB_FILE ); +} + + +KeyboardGlobal::ModelsMap +KeyboardGlobal::getKeyboardModels() +{ + return parseKeyboardModels( XKB_FILE ); +} + +KeyboardGlobal::GroupsMap +KeyboardGlobal::getKeyboardGroups() +{ + return parseKeyboardGroupsSwitchers( XKB_FILE ); +} diff --git a/calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.h b/calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.h new file mode 100644 index 0000000..5166f88 --- /dev/null +++ b/calamares/src/modules/keyboard/keyboardwidget/keyboardglobal.h @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARDGLOBAL_H +#define KEYBOARDGLOBAL_H + +#include +#include + +class KeyboardGlobal +{ +public: + struct KeyboardInfo + { + QString description; + QMap< QString, QString > variants; + }; + + using LayoutsMap = QMap< QString, KeyboardInfo >; + using ModelsMap = QMap< QString, QString >; + using GroupsMap = QMap< QString, QString >; + + static LayoutsMap getKeyboardLayouts(); + static ModelsMap getKeyboardModels(); + static GroupsMap getKeyboardGroups(); +}; + +#endif // KEYBOARDGLOBAL_H diff --git a/calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.cpp b/calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.cpp new file mode 100644 index 0000000..04ca5f2 --- /dev/null +++ b/calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.cpp @@ -0,0 +1,403 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Portions from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "keyboardpreview.h" + +#include "utils/Logger.h" +#include "utils/String.h" + +#include + +KeyBoardPreview::KeyBoardPreview( QWidget* parent ) + : QWidget( parent ) + , layout( "us" ) + , space( 0 ) + , usable_width( 0 ) + , key_w( 0 ) +{ + setMinimumSize( 700, 191 ); + + // We must set up the font size in pixels to fit the keys + lowerFont = QFont( "Helvetica", 10, QFont::DemiBold ); + lowerFont.setPixelSize( 16 ); + upperFont = QFont( "Helvetica", 8 ); + upperFont.setPixelSize( 13 ); + + // Setup keyboard types + kbList[ KB_104 ].kb_extended_return = false; + kbList[ KB_104 ].keys.append( QList< int >() << 0x29 << 0x2 << 0x3 << 0x4 << 0x5 << 0x6 << 0x7 << 0x8 << 0x9 << 0xa + << 0xb << 0xc << 0xd ); + kbList[ KB_104 ].keys.append( QList< int >() << 0x10 << 0x11 << 0x12 << 0x13 << 0x14 << 0x15 << 0x16 << 0x17 << 0x18 + << 0x19 << 0x1a << 0x1b << 0x2b ); + kbList[ KB_104 ].keys.append( QList< int >() << 0x1e << 0x1f << 0x20 << 0x21 << 0x22 << 0x23 << 0x24 << 0x25 << 0x26 + << 0x27 << 0x28 ); + kbList[ KB_104 ].keys.append( QList< int >() + << 0x2c << 0x2d << 0x2e << 0x2f << 0x30 << 0x31 << 0x32 << 0x33 << 0x34 << 0x35 ); + + kbList[ KB_105 ].kb_extended_return = true; + kbList[ KB_105 ].keys.append( QList< int >() << 0x29 << 0x2 << 0x3 << 0x4 << 0x5 << 0x6 << 0x7 << 0x8 << 0x9 << 0xa + << 0xb << 0xc << 0xd ); + kbList[ KB_105 ].keys.append( QList< int >() << 0x10 << 0x11 << 0x12 << 0x13 << 0x14 << 0x15 << 0x16 << 0x17 << 0x18 + << 0x19 << 0x1a << 0x1b ); + kbList[ KB_105 ].keys.append( QList< int >() << 0x1e << 0x1f << 0x20 << 0x21 << 0x22 << 0x23 << 0x24 << 0x25 << 0x26 + << 0x27 << 0x28 << 0x2b ); + kbList[ KB_105 ].keys.append( QList< int >() << 0x54 << 0x2c << 0x2d << 0x2e << 0x2f << 0x30 << 0x31 << 0x32 << 0x33 + << 0x34 << 0x35 ); + + kbList[ KB_106 ].kb_extended_return = true; + kbList[ KB_106 ].keys.append( QList< int >() << 0x29 << 0x2 << 0x3 << 0x4 << 0x5 << 0x6 << 0x7 << 0x8 << 0x9 << 0xa + << 0xb << 0xc << 0xd << 0xe ); + kbList[ KB_106 ].keys.append( QList< int >() << 0x10 << 0x11 << 0x12 << 0x13 << 0x14 << 0x15 << 0x16 << 0x17 << 0x18 + << 0x19 << 0x1a << 0x1b ); + kbList[ KB_106 ].keys.append( QList< int >() << 0x1e << 0x1f << 0x20 << 0x21 << 0x22 << 0x23 << 0x24 << 0x25 << 0x26 + << 0x27 << 0x28 << 0x29 ); + kbList[ KB_106 ].keys.append( QList< int >() << 0x2c << 0x2d << 0x2e << 0x2f << 0x30 << 0x31 << 0x32 << 0x33 << 0x34 + << 0x35 << 0x36 ); + + kb = &kbList[ KB_104 ]; +} + + +void +KeyBoardPreview::setLayout( QString _layout ) +{ + layout = _layout; +} + + +void +KeyBoardPreview::setVariant( QString _variant ) +{ + variant = _variant; + + if ( !loadCodes() ) + { + return; + } + + loadInfo(); + repaint(); +} + + +//### +//### Private +//### + + +void +KeyBoardPreview::loadInfo() +{ + // kb_104 + if ( layout == "us" || layout == "th" ) + { + kb = &kbList[ KB_104 ]; + } + // kb_106 + else if ( layout == "jp" ) + { + kb = &kbList[ KB_106 ]; + } + // most keyboards are 105 key so default to that + else + { + kb = &kbList[ KB_105 ]; + } +} + + +bool +KeyBoardPreview::loadCodes() +{ + if ( layout.isEmpty() ) + { + return false; + } + + QStringList param { "-model", "pc106", "-layout", layout, "-compact" }; + if ( !variant.isEmpty() ) + { + param << "-variant" << variant; + } + + + QProcess process; + process.setEnvironment( QStringList() << "LANG=C" + << "LC_MESSAGES=C" ); + process.start( "ckbcomp", param ); + if ( !process.waitForStarted() ) + { + static bool need_warning = true; + if ( need_warning ) + { + cWarning() << "ckbcomp not found , keyboard preview disabled"; + need_warning = false; + } + return false; + } + + if ( !process.waitForFinished() ) + { + cWarning() << "ckbcomp failed, keyboard preview skipped for" << layout << variant; + return false; + } + + // Clear codes + codes.clear(); + + const QStringList list = QString( process.readAll() ).split( "\n", SplitSkipEmptyParts ); + + for ( const QString& line : list ) + { + if ( !line.startsWith( "keycode" ) || !line.contains( '=' ) ) + { + continue; + } + + QStringList split = line.split( '=' ).at( 1 ).trimmed().split( ' ' ); + if ( split.size() < 4 ) + { + continue; + } + + Code code; + code.plain = fromUnicodeString( split.at( 0 ) ); + code.shift = fromUnicodeString( split.at( 1 ) ); + code.ctrl = fromUnicodeString( split.at( 2 ) ); + code.alt = fromUnicodeString( split.at( 3 ) ); + + if ( code.ctrl == code.plain ) + { + code.ctrl = ""; + } + + if ( code.alt == code.plain ) + { + code.alt = ""; + } + + codes.append( code ); + } + + return true; +} + + +QString +KeyBoardPreview::fromUnicodeString( QString raw ) +{ + if ( raw.startsWith( "U+" ) ) + { + return QChar( raw.mid( 2 ).toInt( nullptr, 16 ) ); + } + else if ( raw.startsWith( "+U" ) ) + { + return QChar( raw.mid( 3 ).toInt( nullptr, 16 ) ); + } + + return ""; +} + + +QString +KeyBoardPreview::regular_text( int index ) +{ + if ( index < 0 || index >= codes.size() ) + { + return ""; + } + + return codes.at( index - 1 ).plain; +} + + +QString +KeyBoardPreview::shift_text( int index ) +{ + if ( index < 0 || index >= codes.size() ) + { + return ""; + } + + return codes.at( index - 1 ).shift; +} + + +QString +KeyBoardPreview::ctrl_text( int index ) +{ + if ( index < 0 || index >= codes.size() ) + { + return ""; + } + + return codes.at( index - 1 ).ctrl; +} + + +QString +KeyBoardPreview::alt_text( int index ) +{ + if ( index < 0 || index >= codes.size() ) + { + return ""; + } + + return codes.at( index - 1 ).alt; +} + + +void +KeyBoardPreview::resizeEvent( QResizeEvent* ) +{ + space = 6; + usable_width = width() - 7; + key_w = ( usable_width - 14 * space ) / 15; + + setMaximumHeight( key_w * 4 + space * 5 + 1 ); +} + + +void +KeyBoardPreview::paintEvent( QPaintEvent* event ) +{ + QPainter p( this ); + p.setRenderHint( QPainter::Antialiasing ); + + p.setBrush( QColor( 0xd6, 0xd6, 0xd6 ) ); + p.drawRect( rect() ); + + QPen pen; + pen.setWidth( 1 ); + pen.setColor( QColor( 0x58, 0x58, 0x58 ) ); + p.setPen( pen ); + + p.setBrush( QColor( 0x58, 0x58, 0x58 ) ); + + p.setBackgroundMode( Qt::TransparentMode ); + p.translate( 0.5, 0.5 ); + + int rx = 3; + int x = 6; + int y = 6; + int first_key_w = 0; + int remaining_x[] = { 0, 0, 0, 0 }; + int remaining_widths[] = { 0, 0, 0, 0 }; + + for ( int i = 0; i < 4; i++ ) + { + if ( first_key_w > 0 ) + { + first_key_w = int( first_key_w * 1.375 ); + + if ( kb == &kbList[ KB_105 ] && i == 3 ) + { + first_key_w = int( key_w * 1.275 ); + } + + p.drawRoundedRect( QRectF( 6, y, first_key_w, key_w ), rx, rx ); + x = 6 + first_key_w + space; + } + else + { + first_key_w = key_w; + } + + + bool last_end = ( i == 1 && !kb->kb_extended_return ); + int rw = usable_width - x; + int ii = 0; + + for ( int k : kb->keys.at( i ) ) + { + QRectF rect = QRectF( x, y, key_w, key_w ); + + if ( ii == kb->keys.at( i ).size() - 1 && last_end ) + { + rect.setWidth( rw ); + } + + p.drawRoundedRect( rect, rx, rx ); + + rect.adjust( 5, 1, 0, 0 ); + + p.setPen( QColor( 0x9e, 0xde, 0x00 ) ); + p.setFont( upperFont ); + p.drawText( rect, Qt::AlignLeft | Qt::AlignTop, shift_text( k ) ); + + rect.setBottom( rect.bottom() - 2.5 ); + + p.setPen( QColor( 0xff, 0xff, 0xff ) ); + p.setFont( lowerFont ); + p.drawText( rect, Qt::AlignLeft | Qt::AlignBottom, regular_text( k ) ); + + rw = rw - space - key_w; + x = x + space + key_w; + ii = ii + 1; + + p.setPen( pen ); + } + + + remaining_x[ i ] = x; + remaining_widths[ i ] = rw; + + if ( i != 1 && i != 2 ) + { + p.drawRoundedRect( QRectF( x, y, rw, key_w ), rx, rx ); + } + + y = y + space + key_w; + } + + + if ( kb->kb_extended_return ) + { + rx = rx * 2; + int x1 = remaining_x[ 1 ]; + int y1 = 6 + key_w * 1 + space * 1; + int w1 = remaining_widths[ 1 ]; + int x2 = remaining_x[ 2 ]; + int y2 = 6 + key_w * 2 + space * 2; + + // this is some serious crap... but it has to be so + // maybe one day keyboards won't look like this... + // one can only hope + QPainterPath pp; + pp.moveTo( x1, y1 + rx ); + pp.arcTo( x1, y1, rx, rx, 180, -90 ); + pp.lineTo( x1 + w1 - rx, y1 ); + pp.arcTo( x1 + w1 - rx, y1, rx, rx, 90, -90 ); + pp.lineTo( x1 + w1, y2 + key_w - rx ); + pp.arcTo( x1 + w1 - rx, y2 + key_w - rx, rx, rx, 0, -90 ); + pp.lineTo( x2 + rx, y2 + key_w ); + pp.arcTo( x2, y2 + key_w - rx, rx, rx, -90, -90 ); + pp.lineTo( x2, y1 + key_w ); + pp.lineTo( x1 + rx, y1 + key_w ); + pp.arcTo( x1, y1 + key_w - rx, rx, rx, -90, -90 ); + pp.closeSubpath(); + + p.drawPath( pp ); + } + else + { + x = remaining_x[ 2 ]; + y = 6 + key_w * 2 + space * 2; + p.drawRoundedRect( QRectF( x, y, remaining_widths[ 2 ], key_w ), rx, rx ); + } + + + QWidget::paintEvent( event ); +} diff --git a/calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.h b/calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.h new file mode 100644 index 0000000..f094a5e --- /dev/null +++ b/calamares/src/modules/keyboard/keyboardwidget/keyboardpreview.h @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Portions from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARDPREVIEW_H +#define KEYBOARDPREVIEW_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +class KeyBoardPreview : public QWidget +{ + Q_OBJECT +public: + explicit KeyBoardPreview( QWidget* parent = nullptr ); + + void setLayout( QString layout ); + void setVariant( QString variant ); + +private: + enum KB_TYPE + { + KB_104, + KB_105, + KB_106 + }; + + struct KB + { + bool kb_extended_return; + QList< QList< int > > keys; + }; + + struct Code + { + QString plain, shift, ctrl, alt; + }; + + QString layout, variant; + QFont lowerFont, upperFont; + KB *kb, kbList[ 3 ]; + QList< Code > codes; + int space, usable_width, key_w; + + void loadInfo(); + bool loadCodes(); + QString regular_text( int index ); + QString shift_text( int index ); + QString ctrl_text( int index ); + QString alt_text( int index ); + QString fromUnicodeString( QString raw ); + +protected: + void paintEvent( QPaintEvent* event ) override; + void resizeEvent( QResizeEvent* event ) override; +}; + +#endif // KEYBOARDPREVIEW_H diff --git a/calamares/src/modules/keyboard/layout-extractor.py b/calamares/src/modules/keyboard/layout-extractor.py new file mode 100644 index 0000000..0827c84 --- /dev/null +++ b/calamares/src/modules/keyboard/layout-extractor.py @@ -0,0 +1,109 @@ +#! /usr/bin/env python3 +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +""" +Python3 script to scrape x keyboard layout file and produce translations. + +To use this script, you must have a base.lst in a standard location, +/usr/local/share/X11/xkb/rules/base.lst (this is usual on FreeBSD). + +Prints out a few tables of keyboard model, layout, variant names for +use in translations. +""" + +def scrape_file(file, modelsset, layoutsset, variantsset, groupsset): + import re + # These RE's match what is in keyboardglobal.cpp + model_re = re.compile("^\\s+(\\S+)\\s+(\\w.*)\n$") + layout_re = re.compile("^\\s+(\\S+)\\s+(\\w.*)\n$") + variant_re = re.compile("^\\s+(\\S+)\\s+(\\S+): (\\w.*)\n$") + group_re = re.compile("^\\s+grp:(\\S+)\\s+(\\w.*)\n$") + + MODEL, LAYOUT, VARIANT, GROUP = range(4) + state = None + for line in file.readlines(): + # Handle changes in section + if line.startswith("! model"): + state = MODEL + continue + elif line.startswith("! layout"): + state = LAYOUT + continue + elif line.startswith("! variant"): + state = VARIANT + continue + elif line.startswith("! option"): + state = GROUP + continue + elif not line.strip(): + state = None + # Unchanged from last blank + if state is None: + continue + + if state == MODEL: + m = model_re.match(line) + name = m.groups()[1] + modelsset.add(name) + if state == LAYOUT: + l = layout_re.match(line) + name = l.groups()[1] + layoutsset.add(name) + if state == VARIANT: + v = variant_re.match(line) + name = v.groups()[2] + variantsset.add(name) + if state == GROUP: + v = group_re.match(line) + if v is None: + continue + name = v.groups()[1] + groupsset.add(name) + + +def write_set(file, label, set): + file.write("/* This returns a reference to local, which is a terrible idea.\n * Good thing it's not meant to be compiled.\n */\n") + # Note {{ is an escaped { for Python string formatting + file.write("class {!s} : public QObject {{\nQ_OBJECT\npublic:\n".format(label)) + file.write("\tconst QStringList& table()\n\t{\n\treturn QStringList {\n") + for x in sorted(set): + file.write("""\t\ttr("{!s}", "{!s}"),\n""".format(x, label)) + file.write("\t\tQString()\n\t};\n}\n}\n\n") + +cpp_header_comment = """/* GENERATED FILE DO NOT EDIT +* +* === This file is part of Calamares - === +* +* SPDX-FileCopyrightText: no +* SPDX-License-Identifier: CC0-1.0 +* +* This file is derived from base.lst in the Xorg distribution +* +*/ + +/** THIS FILE EXISTS ONLY FOR TRANSLATIONS PURPOSES **/ + +// *INDENT-OFF* +// clang-format off +""" + +if __name__ == "__main__": + models=set() + layouts=set() + variants=set() + groups=set() + variants.add( "Default" ) + groups.add( "None" ) + with open("/usr/local/share/X11/xkb/rules/base.lst", "r") as f: + scrape_file(f, models, layouts, variants, groups) + with open("KeyboardData_p.cxxtr", "w") as f: + f.write(cpp_header_comment) + write_set(f, "kb_models", models) + write_set(f, "kb_layouts", layouts) + write_set(f, "kb_variants", variants) + write_set(f, "kb_groups", groups) + diff --git a/calamares/src/modules/keyboard/non-ascii-layouts b/calamares/src/modules/keyboard/non-ascii-layouts new file mode 100644 index 0000000..e7fb7b2 --- /dev/null +++ b/calamares/src/modules/keyboard/non-ascii-layouts @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Layouts stored here need additional layout (usually us) to provide ASCII support for user + +#layout additional-layout additional-variant vconsole-keymap +ru us - ruwin_alt_sh-UTF-8 +ua us - ua-utf +gr us - gr +he us - he +ar us - ar +ir us - fa + +# This list is from /usr/share/X11/xkb/rules/base, all the non-latin +# layouts are collected in $nonlatin . Add us (English) to all of them. +af us - af +am us - am +ara us - ara +bd us - bd +bg us - bg +bt us - bt +by us - by +eg us - eg +et us - et +ge us - ge +gn us - gn +id us - id +il us - il +in us - in +jp us - jp +jv us - jv +kg us - kg +kh us - kh +kr us - kr +kz us - kz +la us - la +lk us - lk +ma us - ma +me us - me +mk us - mk +mm us - mm +mn us - mn +mv us - mv +my us - my +pk us - pk +rs us - rs +sy us - sy +th us - th +tj us - tj +tz us - tz +uz us - uz diff --git a/calamares/src/modules/keyboardq/CMakeLists.txt b/calamares/src/modules/keyboardq/CMakeLists.txt new file mode 100644 index 0000000..e14a59e --- /dev/null +++ b/calamares/src/modules/keyboardq/CMakeLists.txt @@ -0,0 +1,29 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +if(NOT WITH_QML) + calamares_skip_module( "keyboardq (QML is not supported in this build)" ) + return() +endif() + +set(_keyboard ${CMAKE_CURRENT_SOURCE_DIR}/../keyboard) + +include_directories(${_keyboard}) + +calamares_add_plugin(keyboardq + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + KeyboardQmlViewStep.cpp + ${_keyboard}/Config.cpp + ${_keyboard}/KeyboardLayoutModel.cpp + ${_keyboard}/SetKeyboardLayoutJob.cpp + ${_keyboard}/keyboardwidget/keyboardglobal.cpp + RESOURCES + keyboardq${QT_VERSION_SUFFIX}.qrc + SHARED_LIB + LINK_LIBRARIES + ${qtname}::DBus +) diff --git a/calamares/src/modules/keyboardq/KeyboardQmlViewStep.cpp b/calamares/src/modules/keyboardq/KeyboardQmlViewStep.cpp new file mode 100644 index 0000000..1acdd6e --- /dev/null +++ b/calamares/src/modules/keyboardq/KeyboardQmlViewStep.cpp @@ -0,0 +1,100 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Camilo Higuita + * SPDX-FileCopyrightText: 2020 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "KeyboardQmlViewStep.h" + +#include "Config.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( KeyboardQmlViewStepFactory, registerPlugin< KeyboardQmlViewStep >(); ) + +KeyboardQmlViewStep::KeyboardQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) + , m_config( new Config( this ) ) +{ + m_config->detectCurrentKeyboardLayout(); + emit nextStatusChanged( true ); +} + +QString +KeyboardQmlViewStep::prettyName() const +{ + return tr( "Keyboard", "@label" ); +} + +QString +KeyboardQmlViewStep::prettyStatus() const +{ + return m_config->prettyStatus(); +} + +bool +KeyboardQmlViewStep::isNextEnabled() const +{ + return true; +} + +bool +KeyboardQmlViewStep::isBackEnabled() const +{ + return true; +} + +bool +KeyboardQmlViewStep::isAtBeginning() const +{ + return true; +} + +bool +KeyboardQmlViewStep::isAtEnd() const +{ + return true; +} + +Calamares::JobList +KeyboardQmlViewStep::jobs() const +{ + return m_config->createJobs(); +} + +void +KeyboardQmlViewStep::onActivate() +{ + m_config->guessLocaleKeyboardLayout(); +} + +void +KeyboardQmlViewStep::onLeave() +{ + m_config->finalize(); +} + +void +KeyboardQmlViewStep::onCancel() +{ + m_config->cancel(); +} + +QObject* +KeyboardQmlViewStep::getConfig() +{ + return m_config; +} + +void +KeyboardQmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); + Calamares::QmlViewStep::setConfigurationMap( configurationMap ); +} diff --git a/calamares/src/modules/keyboardq/KeyboardQmlViewStep.h b/calamares/src/modules/keyboardq/KeyboardQmlViewStep.h new file mode 100644 index 0000000..540e753 --- /dev/null +++ b/calamares/src/modules/keyboardq/KeyboardQmlViewStep.h @@ -0,0 +1,53 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef KEYBOARDQMLVIEWSTEP_H +#define KEYBOARDQMLVIEWSTEP_H + +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/QmlViewStep.h" + +#include + +class PLUGINDLLEXPORT KeyboardQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + explicit KeyboardQmlViewStep( QObject* parent = nullptr ); + + QString prettyName() const override; + QString prettyStatus() const override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onActivate() override; + void onLeave() override; + void onCancel() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + QObject* getConfig() override; + +private: + Config* m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( KeyboardQmlViewStepFactory ) + +#endif // KEYBOARDQMLVIEWSTEP_H diff --git a/calamares/src/modules/keyboardq/data/Key-qt6.qml b/calamares/src/modules/keyboardq/data/Key-qt6.qml new file mode 100644 index 0000000..990f453 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/Key-qt6.qml @@ -0,0 +1,180 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 - 2023 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import QtQuick + +Item { + id: key + + property string mainLabel: "A" + property var secondaryLabels: []; + + property var iconSource; + + property var keyImageLeft: "" + property var keyImageRight: "" + property var keyImageCenter: "" + + property color keyColor: "#404040" + property color keyPressedColor: "grey" + property int keyBounds: 2 + property var keyPressedColorOpacity: 1 + + property var mainFontFamily: "Roboto" + property color mainFontColor: "white" + property int mainFontSize: 18 + + property var secondaryFontFamily: "Roboto" + property color secondaryFontColor: "white" + property int secondaryFontSize: 10 + + property bool secondaryLabelVisible: true + + property bool isChekable; + property bool isChecked; + + property bool upperCase; + + signal clicked() + signal alternatesClicked(string symbol) + + Item { + anchors.fill: parent + anchors.margins: key.keyBounds + visible: key.keyImageLeft != "" || key.keyImageCenter != "" || key.keyImageRight != "" ? 1 : 0 + Image { + id: backgroundImage_left + anchors.left: parent.left + height: parent.height + fillMode: Image.PreserveAspectFit + source: key.keyImageLeft + } + Image { + id: backgroundImage_right + anchors.right: parent.right + height: parent.height + fillMode: Image.PreserveAspectFit + source: key.keyImageRight + } + Image { + id: backgroundImage_center + anchors.fill: parent + anchors.leftMargin: backgroundImage_left.width - 1 + anchors.rightMargin: backgroundImage_right.width - 1 + height: parent.height + fillMode: Image.Stretch + source: key.keyImageCenter + } + } + + Rectangle { + id: backgroundItem + anchors.fill: parent + anchors.margins: key.keyBounds + color: key.isChecked || mouseArea.pressed ? key.keyPressedColor : key.keyColor; + opacity: key.keyPressedColorOpacity + } + + Column + { + anchors.centerIn: backgroundItem + + Text { + id: secondaryLabelsItem + smooth: true + anchors.right: parent.right + visible: true //secondaryLabelVisible + text: secondaryLabels.length > 0 ? secondaryLabels : "" + color: secondaryFontColor + + font.pixelSize: secondaryFontSize + font.weight: Font.Light + font.family: secondaryFontFamily + font.capitalization: upperCase ? Font.AllUppercase : + Font.MixedCase + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + + Image { + id: icon + smooth: true + anchors.verticalCenter: parent.verticalCenter + source: iconSource + //sourceSize.width: key.width * 0.6 + sourceSize.height: key.height * 0.4 + } + + Text { + id: mainLabelItem + smooth: true + anchors.verticalCenter: parent.verticalCenter + text: mainLabel + color: mainFontColor + visible: iconSource ? false : true + + font.pixelSize: mainFontSize + font.weight: Font.Light + font.family: mainFontFamily + font.capitalization: upperCase ? Font.AllUppercase : + Font.MixedCase + } + } + } + + Row { + id: alternatesRow + property int selectedIndex: -1 + visible: false + anchors.bottom: backgroundItem.top + anchors.left: backgroundItem.left + + Repeater { + model: secondaryLabels.length + + Rectangle { + property bool isSelected: alternatesRow.selectedIndex === index + color: isSelected ? mainLabelItem.color : key.keyPressedColor + height: backgroundItem.height + width: backgroundItem.width + + Text { + anchors.centerIn: parent + text: secondaryLabels[ index ] + font: mainLabelItem.font + color: isSelected ? key.keyPressedColor : mainLabelItem.color + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + onPressAndHold: alternatesRow.visible = true + onClicked: { + if (key.isChekable) key.isChecked = !key.isChecked + key.clicked() + } + + onReleased: { + alternatesRow.visible = false + if (alternatesRow.selectedIndex > -1) + key.alternatesClicked(secondaryLabels[alternatesRow.selectedIndex]) + } + + onMouseXChanged: { + alternatesRow.selectedIndex = + (mouseY < 0 && mouseX > 0 && mouseY < alternatesRow.width) ? + Math.floor(mouseX / backgroundItem.width) : + -1; + } + } +} diff --git a/calamares/src/modules/keyboardq/data/Key.qml b/calamares/src/modules/keyboardq/data/Key.qml new file mode 100644 index 0000000..e5c766e --- /dev/null +++ b/calamares/src/modules/keyboardq/data/Key.qml @@ -0,0 +1,180 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import QtQuick 2.15 + +Item { + id: key + + property string mainLabel: "A" + property var secondaryLabels: []; + + property var iconSource; + + property var keyImageLeft: "" + property var keyImageRight: "" + property var keyImageCenter: "" + + property color keyColor: "#404040" + property color keyPressedColor: "grey" + property int keyBounds: 2 + property var keyPressedColorOpacity: 1 + + property var mainFontFamily: "Roboto" + property color mainFontColor: "white" + property int mainFontSize: 18 + + property var secondaryFontFamily: "Roboto" + property color secondaryFontColor: "white" + property int secondaryFontSize: 10 + + property bool secondaryLabelVisible: true + + property bool isChekable; + property bool isChecked; + + property bool upperCase; + + signal clicked() + signal alternatesClicked(string symbol) + + Item { + anchors.fill: parent + anchors.margins: key.keyBounds + visible: key.keyImageLeft != "" || key.keyImageCenter != "" || key.keyImageRight != "" ? 1 : 0 + Image { + id: backgroundImage_left + anchors.left: parent.left + height: parent.height + fillMode: Image.PreserveAspectFit + source: key.keyImageLeft + } + Image { + id: backgroundImage_right + anchors.right: parent.right + height: parent.height + fillMode: Image.PreserveAspectFit + source: key.keyImageRight + } + Image { + id: backgroundImage_center + anchors.fill: parent + anchors.leftMargin: backgroundImage_left.width - 1 + anchors.rightMargin: backgroundImage_right.width - 1 + height: parent.height + fillMode: Image.Stretch + source: key.keyImageCenter + } + } + + Rectangle { + id: backgroundItem + anchors.fill: parent + anchors.margins: key.keyBounds + color: key.isChecked || mouseArea.pressed ? key.keyPressedColor : key.keyColor; + opacity: key.keyPressedColorOpacity + } + + Column + { + anchors.centerIn: backgroundItem + + Text { + id: secondaryLabelsItem + smooth: true + anchors.right: parent.right + visible: true //secondaryLabelVisible + text: secondaryLabels.length > 0 ? secondaryLabels : "" + color: secondaryFontColor + + font.pixelSize: secondaryFontSize + font.weight: Font.Light + font.family: secondaryFontFamily + font.capitalization: upperCase ? Font.AllUppercase : + Font.MixedCase + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + + Image { + id: icon + smooth: true + anchors.verticalCenter: parent.verticalCenter + source: iconSource + //sourceSize.width: key.width * 0.6 + sourceSize.height: key.height * 0.4 + } + + Text { + id: mainLabelItem + smooth: true + anchors.verticalCenter: parent.verticalCenter + text: mainLabel + color: mainFontColor + visible: iconSource ? false : true + + font.pixelSize: mainFontSize + font.weight: Font.Light + font.family: mainFontFamily + font.capitalization: upperCase ? Font.AllUppercase : + Font.MixedCase + } + } + } + + Row { + id: alternatesRow + property int selectedIndex: -1 + visible: false + anchors.bottom: backgroundItem.top + anchors.left: backgroundItem.left + + Repeater { + model: secondaryLabels.length + + Rectangle { + property bool isSelected: alternatesRow.selectedIndex == index + color: isSelected ? mainLabelItem.color : key.keyPressedColor + height: backgroundItem.height + width: backgroundItem.width + + Text { + anchors.centerIn: parent + text: secondaryLabels[ index ] + font: mainLabelItem.font + color: isSelected ? key.keyPressedColor : mainLabelItem.color + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + onPressAndHold: alternatesRow.visible = true + onClicked: { + if (key.isChekable) key.isChecked = !key.isChecked + key.clicked() + } + + onReleased: { + alternatesRow.visible = false + if (alternatesRow.selectedIndex > -1) + key.alternatesClicked(secondaryLabels[alternatesRow.selectedIndex]) + } + + onMouseXChanged: { + alternatesRow.selectedIndex = + (mouseY < 0 && mouseX > 0 && mouseY < alternatesRow.width) ? + Math.floor(mouseX / backgroundItem.width) : + -1; + } + } +} diff --git a/calamares/src/modules/keyboardq/data/Keyboard-qt6.qml b/calamares/src/modules/keyboardq/data/Keyboard-qt6.qml new file mode 100644 index 0000000..01c5ae9 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/Keyboard-qt6.qml @@ -0,0 +1,224 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 - 2023 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import QtQuick +import QtQml.XmlListModel + +Item { + id: keyboard + + width: 1024 + height: 640 + + property int rows: 4; + property int columns: 10; + + property string source: "generic.xml" + property var target; + + property color backgroundColor: "black" + + property var keyImageLeft: "" + property var keyImageRight: "" + property var keyImageCenter: "" + + property color keyColor: "#404040" + property color keyPressedColor: "grey" + property int keyBounds: 2 + property var keyPressedColorOpacity: 1 + + property var mainFontFamily: "Roboto" + property color mainFontColor: "white" + property int mainFontSize: 59 + + property var secondaryFontFamily: "Roboto" + property color secondaryFontColor: "white" + property int secondaryFontSize: 30 + + property bool secondaryLabelsVisible: false + property bool doSwitchSource: true + + property bool allUpperCase: false + + signal keyClicked(string key) + signal switchSource(string source) + signal enterClicked() + + Rectangle { + id: root + anchors.fill: parent + color: backgroundColor + + property int keyWidth: keyboard.width / columns; + property int keyHeight: keyboard.height / rows; + + property int xmlIndex: 1 + + Text { + id: proxyMainTextItem + color: keyboard.mainFontColor + font.pixelSize: keyboard.mainFontSize + font.weight: Font.Light + font.family: keyboard.mainFontFamily + font.capitalization: keyboard.allUpperCase ? Font.AllUppercase : + Font.MixedCase + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + + Text { + id: proxySecondaryTextItem + color: keyboard.secondaryFontColor + font.pixelSize: keyboard.secondaryFontSize + font.weight: Font.Light + font.family: keyboard.secondaryFontFamily + font.capitalization: keyboard.allUpperCase ? Font.AllUppercase : + Font.MixedCase + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + + Column { + id: column + anchors.centerIn: parent + + Repeater { + id: rowRepeater + + model: 4 + //model: XmlListModel { + // id: keyboardModel + // source: keyboard.source + // query: "/Keyboard/Row" + + //Behavior on source { + // NumberAnimation { + // easing.type: Easing.InOutSine + // duration: 100 + // } + //} + //} + + Row { + id: keyRow + property int rowIndex: index + anchors.horizontalCenter: if(parent) parent.horizontalCenter + + Repeater { + id: keyRepeater + + model: XmlListModel { + source: keyboard.source + query: "/Keyboard/Row" + keyRow.rowIndex + "/Key" + + XmlListModelRole { name: "labels"; attributeName: "labels"; elementName: "" } + XmlListModelRole { name: "ratio"; attributeName: "ratio"; elementName: "" } + XmlListModelRole { name: "icon"; attributeName: "icon"; elementName: "" } + XmlListModelRole { name: "checkable"; attributeName: "checkable"; elementName: "" } + } + + Key { + id: key + width: root.keyWidth * ratio + height: root.keyHeight + iconSource: icon + mainFontFamily: proxyMainTextItem.font + mainFontColor: proxyMainTextItem.color + secondaryFontFamily: proxySecondaryTextItem.font + secondaryFontColor: proxySecondaryTextItem.color + secondaryLabelVisible: keyboard.secondaryLabelsVisible + keyColor: keyboard.keyColor + keyImageLeft: keyboard.keyImageLeft + keyImageRight: keyboard.keyImageRight + keyImageCenter: keyboard.keyImageCenter + keyPressedColor: keyboard.keyPressedColor + keyPressedColorOpacity: keyboard.keyPressedColorOpacity + keyBounds: keyboard.keyBounds + isChekable: checkable + isChecked: isChekable && + command && + command === "shift" && + keyboard.allUpperCase + upperCase: keyboard.allUpperCase + + property var command + property var params: labels + + onParamsChanged: { + var labelSplit; + + if(params[0] === '|') + { + mainLabel = '|' + labelSplit = params + } + else + { + labelSplit = params.split(/[|]+/) + + if (labelSplit[0] === '!') + mainLabel = '!'; + else + mainLabel = params.split(/[!|]+/)[0].toString(); + } + + if (labelSplit[1]) secondaryLabels = labelSplit[1]; + + if (labelSplit[0] === '!') + command = params.split(/[!|]+/)[1]; + else + command = params.split(/[!]+/)[1]; + } + + onClicked: { + if (command) + { + var commandList = command.split(":"); + + switch(commandList[0]) + { + case "source": + keyboard.switchSource(commandList[1]) + if(doSwitchSource) keyboard.source = commandList[1] + return; + case "shift": + keyboard.allUpperCase = !keyboard.allUpperCase + return; + case "backspace": + keyboard.keyClicked('\b'); + target.text = target.text.substring(0,target.text.length-1) + return; + case "enter": + keyboard.enterClicked() + return; + case "tab": + keyboard.keyClicked('\t'); + target.text = target.text + " " + return; + default: return; + } + } + if (mainLabel.length === 1) + root.emitKeyClicked(mainLabel); + } + onAlternatesClicked: root.emitKeyClicked(symbol); + } + } + } + } + } + + function emitKeyClicked(text) { + var emitText = keyboard.allUpperCase ? text.toUpperCase() : text; + keyClicked( emitText ); + target.text = target.text + emitText + } + } +} + diff --git a/calamares/src/modules/keyboardq/data/Keyboard.qml b/calamares/src/modules/keyboardq/data/Keyboard.qml new file mode 100644 index 0000000..5d1356a --- /dev/null +++ b/calamares/src/modules/keyboardq/data/Keyboard.qml @@ -0,0 +1,223 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import QtQuick 2.15 +import QtQuick.XmlListModel 2.10 + +Item { + id: keyboard + + width: 1024 + height: 640 + + property int rows: 4; + property int columns: 10; + + property string source: "generic.xml" + property var target; + + property color backgroundColor: "black" + + property var keyImageLeft: "" + property var keyImageRight: "" + property var keyImageCenter: "" + + property color keyColor: "#404040" + property color keyPressedColor: "grey" + property int keyBounds: 2 + property var keyPressedColorOpacity: 1 + + property var mainFontFamily: "Roboto" + property color mainFontColor: "white" + property int mainFontSize: 59 + + property var secondaryFontFamily: "Roboto" + property color secondaryFontColor: "white" + property int secondaryFontSize: 30 + + property bool secondaryLabelsVisible: false + property bool doSwitchSource: true + + property bool allUpperCase: false + + signal keyClicked(string key) + signal switchSource(string source) + signal enterClicked() + + Rectangle { + id: root + anchors.fill: parent + color: backgroundColor + + property int keyWidth: keyboard.width / columns; + property int keyHeight: keyboard.height / rows; + + property int xmlIndex: 1 + + Text { + id: proxyMainTextItem + color: keyboard.mainFontColor + font.pixelSize: keyboard.mainFontSize + font.weight: Font.Light + font.family: keyboard.mainFontFamily + font.capitalization: keyboard.allUpperCase ? Font.AllUppercase : + Font.MixedCase + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + + Text { + id: proxySecondaryTextItem + color: keyboard.secondaryFontColor + font.pixelSize: keyboard.secondaryFontSize + font.weight: Font.Light + font.family: keyboard.secondaryFontFamily + font.capitalization: keyboard.allUpperCase ? Font.AllUppercase : + Font.MixedCase + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + + Column { + id: column + anchors.centerIn: parent + + Repeater { + id: rowRepeater + + model: XmlListModel { + id: keyboardModel + source: keyboard.source + query: "/Keyboard/Row" + + Behavior on source { + NumberAnimation { + easing.type: Easing.InOutSine + duration: 100 + } + } + } + + Row { + id: keyRow + property int rowIndex: index + anchors.horizontalCenter: if(parent) parent.horizontalCenter + + Repeater { + id: keyRepeater + + model: XmlListModel { + source: keyboard.source + query: "/Keyboard/Row[" + (rowIndex + 1) + "]/Key" + + XmlRole { name: "labels"; query: "@labels/string()" } + XmlRole { name: "ratio"; query: "@ratio/number()" } + XmlRole { name: "icon"; query: "@icon/string()" } + XmlRole { name: "checkable"; query: "@checkable/string()" } + } + + Key { + id: key + width: root.keyWidth * ratio + height: root.keyHeight + iconSource: icon + mainFontFamily: proxyMainTextItem.font + mainFontColor: proxyMainTextItem.color + secondaryFontFamily: proxySecondaryTextItem.font + secondaryFontColor: proxySecondaryTextItem.color + secondaryLabelVisible: keyboard.secondaryLabelsVisible + keyColor: keyboard.keyColor + keyImageLeft: keyboard.keyImageLeft + keyImageRight: keyboard.keyImageRight + keyImageCenter: keyboard.keyImageCenter + keyPressedColor: keyboard.keyPressedColor + keyPressedColorOpacity: keyboard.keyPressedColorOpacity + keyBounds: keyboard.keyBounds + isChekable: checkable + isChecked: isChekable && + command && + command === "shift" && + keyboard.allUpperCase + upperCase: keyboard.allUpperCase + + property var command + property var params: labels + + onParamsChanged: { + var labelSplit; + + if(params[0] === '|') + { + mainLabel = '|' + labelSplit = params + } + else + { + labelSplit = params.split(/[|]+/) + + if (labelSplit[0] === '!') + mainLabel = '!'; + else + mainLabel = params.split(/[!|]+/)[0].toString(); + } + + if (labelSplit[1]) secondaryLabels = labelSplit[1]; + + if (labelSplit[0] === '!') + command = params.split(/[!|]+/)[1]; + else + command = params.split(/[!]+/)[1]; + } + + onClicked: { + if (command) + { + var commandList = command.split(":"); + + switch(commandList[0]) + { + case "source": + keyboard.switchSource(commandList[1]) + if(doSwitchSource) keyboard.source = commandList[1] + return; + case "shift": + keyboard.allUpperCase = !keyboard.allUpperCase + return; + case "backspace": + keyboard.keyClicked('\b'); + target.text = target.text.substring(0,target.text.length-1) + return; + case "enter": + keyboard.enterClicked() + return; + case "tab": + keyboard.keyClicked('\t'); + target.text = target.text + " " + return; + default: return; + } + } + if (mainLabel.length === 1) + root.emitKeyClicked(mainLabel); + } + onAlternatesClicked: root.emitKeyClicked(symbol); + } + } + } + } + } + + function emitKeyClicked(text) { + var emitText = keyboard.allUpperCase ? text.toUpperCase() : text; + keyClicked( emitText ); + target.text = target.text + emitText + } + } +} + diff --git a/calamares/src/modules/keyboardq/data/afgani-qt6.xml b/calamares/src/modules/keyboardq/data/afgani-qt6.xml new file mode 100644 index 0000000..1715a50 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/afgani-qt6.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/afgani.xml b/calamares/src/modules/keyboardq/data/afgani.xml new file mode 100644 index 0000000..8b445b2 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/afgani.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/ar-qt6.xml b/calamares/src/modules/keyboardq/data/ar-qt6.xml new file mode 100644 index 0000000..9cce972 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/ar-qt6.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/ar.xml b/calamares/src/modules/keyboardq/data/ar.xml new file mode 100644 index 0000000..a0e5ad0 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/ar.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/backspace.svg b/calamares/src/modules/keyboardq/data/backspace.svg new file mode 100755 index 0000000..4d29e24 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/backspace.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + diff --git a/calamares/src/modules/keyboardq/data/backspace.svg.license b/calamares/src/modules/keyboardq/data/backspace.svg.license new file mode 100644 index 0000000..36158c6 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/backspace.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019 https://www.onlinewebfonts.com/fonts +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboardq/data/button_bkg_center.png b/calamares/src/modules/keyboardq/data/button_bkg_center.png new file mode 100755 index 0000000000000000000000000000000000000000..d17e1698e0b6a10ecdf25905f34ff88760536a95 GIT binary patch literal 4289 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 Vre~}J{&s*sJTcyGOjpX`e*=nhff)b* literal 0 HcmV?d00001 diff --git a/calamares/src/modules/keyboardq/data/button_bkg_center.png.license b/calamares/src/modules/keyboardq/data/button_bkg_center.png.license new file mode 100644 index 0000000..d36c167 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/button_bkg_center.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019 MarcoPellin +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboardq/data/button_bkg_left.png b/calamares/src/modules/keyboardq/data/button_bkg_left.png new file mode 100755 index 0000000000000000000000000000000000000000..0a674af6c1a014846efe6cb3531deb7d75b5eb4d GIT binary patch literal 5549 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 +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboardq/data/button_bkg_right.png b/calamares/src/modules/keyboardq/data/button_bkg_right.png new file mode 100755 index 0000000000000000000000000000000000000000..ce7c4ad7146a5fe93dffe60cd90024de6b2ff6d7 GIT binary patch literal 5955 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 aNn=Hcm+F7`RDc;bY8Pqm&gws?$^QpEvgoh? literal 0 HcmV?d00001 diff --git a/calamares/src/modules/keyboardq/data/button_bkg_right.png.license b/calamares/src/modules/keyboardq/data/button_bkg_right.png.license new file mode 100644 index 0000000..d36c167 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/button_bkg_right.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019 MarcoPellin +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboardq/data/de-qt6.xml b/calamares/src/modules/keyboardq/data/de-qt6.xml new file mode 100644 index 0000000..16e6bc5 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/de-qt6.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/de.xml b/calamares/src/modules/keyboardq/data/de.xml new file mode 100644 index 0000000..883d4dd --- /dev/null +++ b/calamares/src/modules/keyboardq/data/de.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/empty-qt6.xml b/calamares/src/modules/keyboardq/data/empty-qt6.xml new file mode 100644 index 0000000..94f9a1f --- /dev/null +++ b/calamares/src/modules/keyboardq/data/empty-qt6.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/empty.xml b/calamares/src/modules/keyboardq/data/empty.xml new file mode 100644 index 0000000..a8afccb --- /dev/null +++ b/calamares/src/modules/keyboardq/data/empty.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/en-qt6.xml b/calamares/src/modules/keyboardq/data/en-qt6.xml new file mode 100644 index 0000000..70d7454 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/en-qt6.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/en.xml b/calamares/src/modules/keyboardq/data/en.xml new file mode 100644 index 0000000..3602f1d --- /dev/null +++ b/calamares/src/modules/keyboardq/data/en.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/enter.svg b/calamares/src/modules/keyboardq/data/enter.svg new file mode 100755 index 0000000..c66a749 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/enter.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/enter.svg.license b/calamares/src/modules/keyboardq/data/enter.svg.license new file mode 100644 index 0000000..36158c6 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/enter.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019 https://www.onlinewebfonts.com/fonts +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboardq/data/es-qt6.xml b/calamares/src/modules/keyboardq/data/es-qt6.xml new file mode 100644 index 0000000..bc627d8 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/es-qt6.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/es.xml b/calamares/src/modules/keyboardq/data/es.xml new file mode 100644 index 0000000..3cac9be --- /dev/null +++ b/calamares/src/modules/keyboardq/data/es.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/fr-qt6.xml b/calamares/src/modules/keyboardq/data/fr-qt6.xml new file mode 100644 index 0000000..27ad5a7 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/fr-qt6.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/fr.xml b/calamares/src/modules/keyboardq/data/fr.xml new file mode 100644 index 0000000..5328c49 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/fr.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/generic-qt6.xml b/calamares/src/modules/keyboardq/data/generic-qt6.xml new file mode 100644 index 0000000..bcf35bb --- /dev/null +++ b/calamares/src/modules/keyboardq/data/generic-qt6.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/generic.xml b/calamares/src/modules/keyboardq/data/generic.xml new file mode 100644 index 0000000..1be4ec4 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/generic.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/generic_qz-qt6.xml b/calamares/src/modules/keyboardq/data/generic_qz-qt6.xml new file mode 100644 index 0000000..a5e4f5b --- /dev/null +++ b/calamares/src/modules/keyboardq/data/generic_qz-qt6.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/generic_qz.xml b/calamares/src/modules/keyboardq/data/generic_qz.xml new file mode 100644 index 0000000..b8e36cd --- /dev/null +++ b/calamares/src/modules/keyboardq/data/generic_qz.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/pan-end-symbolic.svg b/calamares/src/modules/keyboardq/data/pan-end-symbolic.svg new file mode 100644 index 0000000..0a398fc --- /dev/null +++ b/calamares/src/modules/keyboardq/data/pan-end-symbolic.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/pan-end-symbolic.svg.license b/calamares/src/modules/keyboardq/data/pan-end-symbolic.svg.license new file mode 100644 index 0000000..ab91fa2 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/pan-end-symbolic.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2022 demmm +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboardq/data/pt-qt6.xml b/calamares/src/modules/keyboardq/data/pt-qt6.xml new file mode 100644 index 0000000..a7e970d --- /dev/null +++ b/calamares/src/modules/keyboardq/data/pt-qt6.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/pt.xml b/calamares/src/modules/keyboardq/data/pt.xml new file mode 100644 index 0000000..fa883f0 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/pt.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/ru-qt6.xml b/calamares/src/modules/keyboardq/data/ru-qt6.xml new file mode 100644 index 0000000..472c219 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/ru-qt6.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/ru.xml b/calamares/src/modules/keyboardq/data/ru.xml new file mode 100644 index 0000000..729ff69 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/ru.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/scan.xml b/calamares/src/modules/keyboardq/data/scan.xml new file mode 100644 index 0000000..efdb01d --- /dev/null +++ b/calamares/src/modules/keyboardq/data/scan.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/keyboardq/data/shift.svg b/calamares/src/modules/keyboardq/data/shift.svg new file mode 100755 index 0000000..825ba64 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/shift.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + diff --git a/calamares/src/modules/keyboardq/data/shift.svg.license b/calamares/src/modules/keyboardq/data/shift.svg.license new file mode 100644 index 0000000..36158c6 --- /dev/null +++ b/calamares/src/modules/keyboardq/data/shift.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2019 https://www.onlinewebfonts.com/fonts +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/keyboardq/keyboardq-qt6.qml b/calamares/src/modules/keyboardq/keyboardq-qt6.qml new file mode 100644 index 0000000..d0b1f85 --- /dev/null +++ b/calamares/src/modules/keyboardq/keyboardq-qt6.qml @@ -0,0 +1,356 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2023 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami +import "data" + +Item { + width: 800 //parent.width + height: 600 + + readonly property color backgroundColor: "#E6E9EA" //Kirigami.Theme.backgroundColor + readonly property color listBackgroundColor: "white" + readonly property color textFieldColor: "#121212" + readonly property color textFieldBackgroundColor: "#F8F8F8" + readonly property color textColor: Kirigami.Theme.textColor + readonly property color highlightedTextColor: Kirigami.Theme.highlightedTextColor + readonly property color highlightColor: Kirigami.Theme.highlightColor + + property var langXml: ["de", "en", "es", "fr", "ru",] + property var arXml: ["Arabic"] + property var ruXml: ["Azerba", "Belaru", "Kazakh", "Kyrgyz", "Mongol", + "Russia", "Tajik", "Ukrain"] + property var frXml: ["Bambar", "Belgia","French", "Wolof"] + property var enXml: ["Bikol", "Chines", "Englis", "Irish", "Lithua", "Maori"] + property var esXml: ["Spanis"] + property var deXml: ["German"] + property var ptXml: ["Portug"] + property var scanXml: ["Danish", "Finnis", "Norweg", "Swedis"] + property var afganiXml: ["Afghan"] + property var genericXml: ["Armeni", "Bulgar", "Dutch", "Estoni", "Icelan", + "Indone", "Italia", "Latvia", "Maltes", "Moldav", "Romani", "Swahil", "Turkis"] + property var genericQzXml: ["Albani", "Bosnia", "Croati", "Czech", "Hungar", + "Luxemb", "Monten", "Polish", "Serbia", "Sloven", "Slovak"] + property var genericAzXml: [] + + property var keyIndex: [] + + Rectangle { + id: backgroundItem + anchors.fill: parent + width: 800 + color: backgroundColor + + Label { + id: header + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Select a layout to activate keyboard preview", "@label") + color: textColor + font.bold: true + } + + Drawer { + id: drawer + width: 0.4 * backgroundItem.width + height: backgroundItem.height + edge: Qt.RightEdge + + ScrollView { + id: scroll1 + anchors.fill: parent + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: models + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + width: parent.width + + model: config.keyboardModelsModel + Component.onCompleted: positionViewAtIndex(model.currentIndex, ListView.Center) + currentIndex: model.currentIndex + delegate: ItemDelegate { + + property variant currentModel: model + hoverEnabled: true + width: 0.4 * backgroundItem.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + Label { + Layout.fillHeight: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + width: parent.width + height: 24 + color: highlighted ? "#eff0f1" : "#1F1F1F" + text: model.label + background: Rectangle { + + color: highlighted || hovered ? "#3498DB" : "#ffffff" + opacity: highlighted || hovered ? 0.5 : 0.9 + } + + MouseArea { + hoverEnabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + models.currentIndex = index + drawer.close() + } + } + } + } + onCurrentItemChanged: { config.keyboardModels = model[currentIndex] } /* This works because model is a stringlist */ + } + } + } + + Rectangle { + id: modelLabel + anchors.top: header.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width / 1.5 + height: 36 + color: mouseBar.containsMouse ? "#eff0f1" : "transparent"; + + MouseArea { + id: mouseBar + anchors.fill: parent; + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + + onClicked: { + drawer.open() + } + Text { + anchors.centerIn: parent + text: qsTr("Keyboard model:  ", "@label") + models.currentItem.currentModel.label + color: textColor + } + Image { + source: "data/pan-end-symbolic.svg" + anchors.centerIn: parent + anchors.horizontalCenterOffset : parent.width / 2.5 + fillMode: Image.PreserveAspectFit + height: 22 + } + } + } + + RowLayout { + id: stack + anchors.top: modelLabel.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width / 1.1 + spacing: 10 + + ListView { + id: layouts + + ScrollBar.vertical: ScrollBar { + active: true + } + + Layout.preferredWidth: parent.width / 2 + height: 220 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + headerPositioning: ListView.OverlayHeader + header: Rectangle{ + height: 24 + width: parent.width + z: 2 + color:backgroundColor + Text { + text: qsTr("Layout", "@label") + anchors.centerIn: parent + color: textColor + font.bold: true + } + } + + Rectangle { + z: parent.z - 1 + anchors.fill: parent + color: listBackgroundColor + opacity: 0.7 + } + + model: config.keyboardLayoutsModel + currentIndex: model.currentIndex + Component.onCompleted: positionViewAtIndex(model.currentIndex, ListView.Center) + delegate: ItemDelegate { + + hoverEnabled: true + width: parent.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + + RowLayout { + anchors.fill: parent + + Label { + id: label1 + text: model.label + horizontalAlignment: Text.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + width: parent.width + height: 24 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + color: highlighted || hovered ? highlightColor : listBackgroundColor + opacity: highlighted || hovered ? 0.5 : 0.3 + } + } + } + + onClicked: { + + layouts.model.currentIndex = index + keyIndex = label1.text.substring(0,6) + layouts.positionViewAtIndex(index, ListView.Center) + } + } + } + + ListView { + id: variants + + ScrollBar.vertical: ScrollBar { + active: true + } + + Layout.preferredWidth: parent.width / 2 + height: 220 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + headerPositioning: ListView.OverlayHeader + header: Rectangle{ + height: 24 + width: parent.width + z: 2 + color:backgroundColor + Text { + text: qsTr("Variant", "@label") + anchors.centerIn: parent + color: textColor + font.bold: true + } + } + + Rectangle { + z: parent.z - 1 + anchors.fill: parent + color: listBackgroundColor + opacity: 0.7 + } + + model: config.keyboardVariantsModel + currentIndex: model.currentIndex + Component.onCompleted: positionViewAtIndex(model.currentIndex, ListView.Center) + + delegate: ItemDelegate { + hoverEnabled: true + width: parent.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + + RowLayout { + anchors.fill: parent + + Label { + text: model.label + horizontalAlignment: Text.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + width: parent.width + height: 24 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + color: highlighted || hovered ? highlightColor : listBackgroundColor + opacity: highlighted || hovered ? 0.5 : 0.3 + } + } + } + + onClicked: { + variants.model.currentIndex = index + variants.positionViewAtIndex(index, ListView.Center) + } + } + } + } + + TextField { + id: textInput + placeholderText: qsTr("Type here to test your keyboard…", "@label") + height: 36 + width: parent.width / 1.6 + horizontalAlignment: TextInput.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: keyboard.top + anchors.bottomMargin: parent.height / 25 + color: textFieldColor + + background:Rectangle { + z: parent.z - 1 + anchors.fill: parent + color: textFieldBackgroundColor + radius: 2 + } + } + + Keyboard { + id: keyboard + width: parent.width + height: parent.height / 3 + anchors.bottom: parent.bottom + source: langXml.includes(keyIndex) ? (keyIndex + ".xml") : + afganiXml.includes(keyIndex) ? "afgani.xml" : + scanXml.includes(keyIndex) ? "scan.xml" : + genericXml.includes(keyIndex) ? "generic.xml" : + genericQzXml.includes(keyIndex) ? "generic_qz.xml" : + arXml.includes(keyIndex) ? "ar.xml" : + deXml.includes(keyIndex) ? "de.xml" : + enXml.includes(keyIndex) ? "en.xml" : + esXml.includes(keyIndex) ? "es.xml" : + frXml.includes(keyIndex) ? "fr.xml" : + ptXml.includes(keyIndex) ? "pt.xml" : + ruXml.includes(keyIndex) ? "ru.xml" :"empty.xml" + rows: 4 + columns: 10 + keyColor: "transparent" + keyPressedColorOpacity: 0.2 + keyImageLeft: "button_bkg_left.png" + keyImageRight: "button_bkg_right.png" + keyImageCenter: "button_bkg_center.png" + target: textInput + onEnterClicked: console.log("Enter!") + } + } +} diff --git a/calamares/src/modules/keyboardq/keyboardq-qt6.qrc b/calamares/src/modules/keyboardq/keyboardq-qt6.qrc new file mode 100644 index 0000000..c90b24b --- /dev/null +++ b/calamares/src/modules/keyboardq/keyboardq-qt6.qrc @@ -0,0 +1,29 @@ + + + ../keyboard/kbd-model-map + ../keyboard/images/restore.png + ../keyboard/non-ascii-layouts + keyboardq-qt6.qml + data/Keyboard-qt6.qml + data/Key-qt6.qml + data/backspace.svg + data/enter.svg + data/shift.svg + data/afgani-qt6.xml + data/ar-qt6.xml + data/de-qt6.xml + data/en-qt6.xml + data/empty-qt6.xml + data/es-qt6.xml + data/fr-qt6.xml + data/generic_qz-qt6.xml + data/generic-qt6.xml + data/pt-qt6.xml + data/ru-qt6.xml + data/scan.xml + data/button_bkg_center.png + data/button_bkg_left.png + data/button_bkg_right.png + data/pan-end-symbolic.svg + + diff --git a/calamares/src/modules/keyboardq/keyboardq.conf b/calamares/src/modules/keyboardq/keyboardq.conf new file mode 100644 index 0000000..d122f30 --- /dev/null +++ b/calamares/src/modules/keyboardq/keyboardq.conf @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# NOTE: you must have ckbcomp installed and runnable +# on the live system, for keyboard layout previews. +--- +# The name of the file to write X11 keyboard settings to +# The default value is the name used by upstream systemd-localed. +# Relative paths are assumed to be relative to /etc/X11/xorg.conf.d +xOrgConfFileName: "/etc/X11/xorg.conf.d/00-keyboard.conf" + +# The path to search for keymaps converted from X11 to kbd format +# Leave this empty if the setting does not make sense on your distribution. +convertedKeymapPath: "/lib/kbd/keymaps/xkb" + +# Write keymap configuration to /etc/default/keyboard, usually +# found on Debian-related systems. +# Defaults to true if nothing is set. +#writeEtcDefaultKeyboard: true diff --git a/calamares/src/modules/keyboardq/keyboardq.qml b/calamares/src/modules/keyboardq/keyboardq.qml new file mode 100644 index 0000000..ad83a99 --- /dev/null +++ b/calamares/src/modules/keyboardq/keyboardq.qml @@ -0,0 +1,356 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2022 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Window 2.15 +import QtQuick.Layouts 1.3 + +import org.kde.kirigami 2.7 as Kirigami +import "data" + +Item { + width: 800 //parent.width + height: 600 + + readonly property color backgroundColor: "#E6E9EA" //Kirigami.Theme.backgroundColor + readonly property color listBackgroundColor: "white" + readonly property color textFieldColor: "#121212" + readonly property color textFieldBackgroundColor: "#F8F8F8" + readonly property color textColor: Kirigami.Theme.textColor + readonly property color highlightedTextColor: Kirigami.Theme.highlightedTextColor + readonly property color highlightColor: Kirigami.Theme.highlightColor + + property var langXml: ["de", "en", "es", "fr", "ru",] + property var arXml: ["Arabic"] + property var ruXml: ["Azerba", "Belaru", "Kazakh", "Kyrgyz", "Mongol", + "Russia", "Tajik", "Ukrain"] + property var frXml: ["Bambar", "Belgia","French", "Wolof"] + property var enXml: ["Bikol", "Chines", "Englis", "Irish", "Lithua", "Maori"] + property var esXml: ["Spanis"] + property var deXml: ["German"] + property var ptXml: ["Portug"] + property var scanXml: ["Danish", "Finnis", "Norweg", "Swedis"] + property var afganiXml: ["Afghan"] + property var genericXml: ["Armeni", "Bulgar", "Dutch", "Estoni", "Icelan", + "Indone", "Italia", "Latvia", "Maltes", "Moldav", "Romani", "Swahil", "Turkis"] + property var genericQzXml: ["Albani", "Bosnia", "Croati", "Czech", "Hungar", + "Luxemb", "Monten", "Polish", "Serbia", "Sloven", "Slovak"] + property var genericAzXml: [] + + property var keyIndex: [] + + Rectangle { + id: backgroundItem + anchors.fill: parent + width: 800 + color: backgroundColor + + Label { + id: header + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Select a layout to activate keyboard preview", "@label") + color: textColor + font.bold: true + } + + Drawer { + id: drawer + width: 0.4 * backgroundItem.width + height: backgroundItem.height + edge: Qt.RightEdge + + ScrollView { + id: scroll1 + anchors.fill: parent + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: models + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + width: parent.width + + model: config.keyboardModelsModel + Component.onCompleted: positionViewAtIndex(model.currentIndex, ListView.Center) + currentIndex: model.currentIndex + delegate: ItemDelegate { + + property variant currentModel: model + hoverEnabled: true + width: 0.4 * backgroundItem.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + Label { + Layout.fillHeight: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + width: parent.width + height: 24 + color: highlighted ? "#eff0f1" : "#1F1F1F" + text: model.label + background: Rectangle { + + color: highlighted || hovered ? "#3498DB" : "#ffffff" + opacity: highlighted || hovered ? 0.5 : 0.9 + } + + MouseArea { + hoverEnabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + models.currentIndex = index + drawer.close() + } + } + } + } + onCurrentItemChanged: { config.keyboardModels = model[currentIndex] } /* This works because model is a stringlist */ + } + } + } + + Rectangle { + id: modelLabel + anchors.top: header.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width / 1.5 + height: 36 + color: mouseBar.containsMouse ? "#eff0f1" : "transparent"; + + MouseArea { + id: mouseBar + anchors.fill: parent; + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + + onClicked: { + drawer.open() + } + Text { + anchors.centerIn: parent + text: qsTr("Keyboard model:  ", "@label") + models.currentItem.currentModel.label + color: textColor + } + Image { + source: "data/pan-end-symbolic.svg" + anchors.centerIn: parent + anchors.horizontalCenterOffset : parent.width / 2.5 + fillMode: Image.PreserveAspectFit + height: 22 + } + } + } + + RowLayout { + id: stack + anchors.top: modelLabel.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width / 1.1 + spacing: 10 + + ListView { + id: layouts + + ScrollBar.vertical: ScrollBar { + active: true + } + + Layout.preferredWidth: parent.width / 2 + height: 220 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + headerPositioning: ListView.OverlayHeader + header: Rectangle{ + height: 24 + width: parent.width + z: 2 + color:backgroundColor + Text { + text: qsTr("Layout", "@label") + anchors.centerIn: parent + color: textColor + font.bold: true + } + } + + Rectangle { + z: parent.z - 1 + anchors.fill: parent + color: listBackgroundColor + opacity: 0.7 + } + + model: config.keyboardLayoutsModel + currentIndex: model.currentIndex + Component.onCompleted: positionViewAtIndex(model.currentIndex, ListView.Center) + delegate: ItemDelegate { + + hoverEnabled: true + width: parent.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + + RowLayout { + anchors.fill: parent + + Label { + id: label1 + text: model.label + horizontalAlignment: Text.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + width: parent.width + height: 24 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + color: highlighted || hovered ? highlightColor : listBackgroundColor + opacity: highlighted || hovered ? 0.5 : 0.3 + } + } + } + + onClicked: { + + layouts.model.currentIndex = index + keyIndex = label1.text.substring(0,6) + layouts.positionViewAtIndex(index, ListView.Center) + } + } + } + + ListView { + id: variants + + ScrollBar.vertical: ScrollBar { + active: true + } + + Layout.preferredWidth: parent.width / 2 + height: 220 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + headerPositioning: ListView.OverlayHeader + header: Rectangle{ + height: 24 + width: parent.width + z: 2 + color:backgroundColor + Text { + text: qsTr("Variant", "@label") + anchors.centerIn: parent + color: textColor + font.bold: true + } + } + + Rectangle { + z: parent.z - 1 + anchors.fill: parent + color: listBackgroundColor + opacity: 0.7 + } + + model: config.keyboardVariantsModel + currentIndex: model.currentIndex + Component.onCompleted: positionViewAtIndex(model.currentIndex, ListView.Center) + + delegate: ItemDelegate { + hoverEnabled: true + width: parent.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + + RowLayout { + anchors.fill: parent + + Label { + text: model.label + horizontalAlignment: Text.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + width: parent.width + height: 24 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + color: highlighted || hovered ? highlightColor : listBackgroundColor + opacity: highlighted || hovered ? 0.5 : 0.3 + } + } + } + + onClicked: { + variants.model.currentIndex = index + variants.positionViewAtIndex(index, ListView.Center) + } + } + } + } + + TextField { + id: textInput + placeholderText: qsTr("Type here to test your keyboard…", "@label") + height: 36 + width: parent.width / 1.6 + horizontalAlignment: TextInput.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: keyboard.top + anchors.bottomMargin: parent.height / 25 + color: textFieldColor + + background:Rectangle { + z: parent.z - 1 + anchors.fill: parent + color: textFieldBackgroundColor + radius: 2 + } + } + + Keyboard { + id: keyboard + width: parent.width + height: parent.height / 3 + anchors.bottom: parent.bottom + source: langXml.includes(keyIndex) ? (keyIndex + ".xml") : + afganiXml.includes(keyIndex) ? "afgani.xml" : + scanXml.includes(keyIndex) ? "scan.xml" : + genericXml.includes(keyIndex) ? "generic.xml" : + genericQzXml.includes(keyIndex) ? "generic_qz.xml" : + arXml.includes(keyIndex) ? "ar.xml" : + deXml.includes(keyIndex) ? "de.xml" : + enXml.includes(keyIndex) ? "en.xml" : + esXml.includes(keyIndex) ? "es.xml" : + frXml.includes(keyIndex) ? "fr.xml" : + ptXml.includes(keyIndex) ? "pt.xml" : + ruXml.includes(keyIndex) ? "ru.xml" :"empty.xml" + rows: 4 + columns: 10 + keyColor: "transparent" + keyPressedColorOpacity: 0.2 + keyImageLeft: "button_bkg_left.png" + keyImageRight: "button_bkg_right.png" + keyImageCenter: "button_bkg_center.png" + target: textInput + onEnterClicked: console.log("Enter!") + } + } +} diff --git a/calamares/src/modules/keyboardq/keyboardq.qrc b/calamares/src/modules/keyboardq/keyboardq.qrc new file mode 100644 index 0000000..ad777fd --- /dev/null +++ b/calamares/src/modules/keyboardq/keyboardq.qrc @@ -0,0 +1,29 @@ + + + ../keyboard/kbd-model-map + ../keyboard/images/restore.png + ../keyboard/non-ascii-layouts + keyboardq.qml + data/Keyboard.qml + data/Key.qml + data/backspace.svg + data/enter.svg + data/shift.svg + data/afgani.xml + data/ar.xml + data/de.xml + data/en.xml + data/empty.xml + data/es.xml + data/fr.xml + data/generic_qz.xml + data/generic.xml + data/pt.xml + data/ru.xml + data/scan.xml + data/button_bkg_center.png + data/button_bkg_left.png + data/button_bkg_right.png + data/pan-end-symbolic.svg + + diff --git a/calamares/src/modules/license/CMakeLists.txt b/calamares/src/modules/license/CMakeLists.txt new file mode 100644 index 0000000..d214d6c --- /dev/null +++ b/calamares/src/modules/license/CMakeLists.txt @@ -0,0 +1,18 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +include_directories(${PROJECT_BINARY_DIR}/src/libcalamaresui) + +calamares_add_plugin(license + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + LicensePage.cpp + LicenseViewStep.cpp + LicenseWidget.cpp + UI + LicensePage.ui + SHARED_LIB +) diff --git a/calamares/src/modules/license/LicensePage.cpp b/calamares/src/modules/license/LicensePage.cpp new file mode 100644 index 0000000..b35c4e1 --- /dev/null +++ b/calamares/src/modules/license/LicensePage.cpp @@ -0,0 +1,205 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Anke Boersma + * SPDX-FileCopyrightText: 2015 Alexandre Arnt + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LicensePage.h" + +#include "LicenseWidget.h" +#include "ui_LicensePage.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "ViewManager.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Retranslator.h" +#include "utils/Variant.h" + +#include +#include +#include +#include +#include +#include + +#include + +static const char mustAccept[] = "#acceptFrame { border: 1px solid red;" + "background-color: #fff6f6;" + "border-radius: 4px;" + "padding: 2px; }"; +static const char okAccept[] = "#acceptFrame { padding: 3px }"; + +const NamedEnumTable< LicenseEntry::Type >& +LicenseEntry::typeNames() +{ + static const NamedEnumTable< LicenseEntry::Type > names { + { QStringLiteral( "software" ), LicenseEntry::Type::Software }, + { QStringLiteral( "driver" ), LicenseEntry::Type::Driver }, + { QStringLiteral( "gpudriver" ), LicenseEntry::Type::GpuDriver }, + { QStringLiteral( "browserplugin" ), LicenseEntry::Type::BrowserPlugin }, + { QStringLiteral( "codec" ), LicenseEntry::Type::Codec }, + { QStringLiteral( "package" ), LicenseEntry::Type::Package } + }; + + return names; +} + +LicenseEntry::LicenseEntry( const QVariantMap& conf ) +{ + if ( !conf.contains( "id" ) || !conf.contains( "name" ) || !conf.contains( "url" ) ) + { + return; + } + + m_id = conf[ "id" ].toString(); + m_prettyName = conf[ "name" ].toString(); + m_prettyVendor = conf.value( "vendor" ).toString(); + m_url = QUrl( conf[ "url" ].toString() ); + + m_required = Calamares::getBool( conf, "required", false ); + m_expand = Calamares::getBool( conf, "expand", false ); + + bool ok = false; + QString typeString = conf.value( "type", "software" ).toString(); + m_type = typeNames().find( typeString, ok ); + if ( !ok ) + { + cWarning() << "License entry" << m_id << "has unknown type" << typeString << "(using 'software')"; + } +} + +bool +LicenseEntry::isLocal() const +{ + return m_url.isLocalFile(); +} + +LicensePage::LicensePage( QWidget* parent ) + : QWidget( parent ) + , m_isNextEnabled( false ) + , m_allLicensesOptional( false ) + , ui( new Ui::LicensePage ) +{ + ui->setupUi( this ); + + // ui->verticalLayout->insertSpacing( 1, Calamares::defaultFontHeight() ); + Calamares::unmarginLayout( ui->verticalLayout ); + + ui->acceptFrame->setStyleSheet( mustAccept ); + { + // The inner frame was unmargined (above), reinstate margins so all are + // the same *x* (an x-height, approximately). + const auto x = Calamares::defaultFontHeight() / 2; + ui->acceptFrame->layout()->setContentsMargins( x, x, x, x ); + } + + updateGlobalStorage( false ); // Have not agreed yet + + connect( ui->acceptCheckBox, &QCheckBox::toggled, this, &LicensePage::checkAcceptance ); + + CALAMARES_RETRANSLATE_SLOT( &LicensePage::retranslate ); +} + +void +LicensePage::setEntries( const QList< LicenseEntry >& entriesList ) +{ + for ( QWidget* w : m_entries ) + { + ui->licenseEntriesLayout->removeWidget( w ); + w->deleteLater(); + } + + m_allLicensesOptional = true; + + m_entries.clear(); + m_entries.reserve( entriesList.count() ); + for ( const LicenseEntry& entry : entriesList ) + { + LicenseWidget* w = new LicenseWidget( entry ); + ui->licenseEntriesLayout->addWidget( w ); + m_entries.append( w ); + m_allLicensesOptional &= !entry.isRequired(); + } + + ui->acceptCheckBox->setChecked( false ); + checkAcceptance( false ); +} + +void +LicensePage::retranslate() +{ + ui->acceptCheckBox->setText( tr( "I accept the terms and conditions above.", "@info" ) ); + + QString review = tr( "Please review the End User License Agreements (EULAs).", "@info" ); + const auto br = QStringLiteral( "
" ); + + if ( !m_allLicensesOptional ) + { + ui->mainText->setText( tr( "This setup procedure will install proprietary " + "software that is subject to licensing terms.", + "@info" ) + + br + review ); + QString mustAcceptText( + tr( "If you do not agree with the terms, the setup procedure cannot continue.", "@info" ) ); + ui->acceptCheckBox->setToolTip( mustAcceptText ); + } + else + { + ui->mainText->setText( tr( "This setup procedure can install proprietary " + "software that is subject to licensing terms " + "in order to provide additional features and enhance the user " + "experience.", + "@info" ) + + br + review ); + QString okAcceptText( tr( "If you do not agree with the terms, proprietary software will not " + "be installed, and open source alternatives will be used instead.", + "@info" ) ); + ui->acceptCheckBox->setToolTip( okAcceptText ); + } + ui->retranslateUi( this ); + + for ( const auto& w : m_entries ) + { + w->retranslateUi(); + } +} + +bool +LicensePage::isNextEnabled() const +{ + return m_isNextEnabled; +} + +void +LicensePage::updateGlobalStorage( bool v ) +{ + Calamares::JobQueue::instance()->globalStorage()->insert( "licenseAgree", v ); +} + +void +LicensePage::checkAcceptance( bool checked ) +{ + updateGlobalStorage( checked ); + + m_isNextEnabled = checked || m_allLicensesOptional; + if ( !m_isNextEnabled ) + { + ui->acceptFrame->setStyleSheet( mustAccept ); + } + else + { + ui->acceptFrame->setStyleSheet( okAccept ); + } + emit nextStatusChanged( m_isNextEnabled ); +} diff --git a/calamares/src/modules/license/LicensePage.h b/calamares/src/modules/license/LicensePage.h new file mode 100644 index 0000000..cfd991e --- /dev/null +++ b/calamares/src/modules/license/LicensePage.h @@ -0,0 +1,100 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Anke Boersma + * SPDX-FileCopyrightText: 2015 Alexandre Arnt + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LICENSEPAGE_H +#define LICENSEPAGE_H + +#include "utils/NamedEnum.h" + +#include +#include + +namespace Ui +{ +class LicensePage; +} // namespace Ui + +class LicenseWidget; + +struct LicenseEntry +{ + enum class Type + { + Software = 0, + Driver, + GpuDriver, + BrowserPlugin, + Codec, + Package + }; + + /// @brief Lookup table for the enums + const NamedEnumTable< Type >& typeNames(); + + LicenseEntry( const QVariantMap& conf ); + LicenseEntry( const LicenseEntry& ) = default; + LicenseEntry& operator=( const LicenseEntry& ) = default; + + bool isValid() const { return !m_id.isEmpty(); } + bool isRequired() const { return m_required; } + bool isLocal() const; + bool expandByDefault() const { return m_expand; } + + QString m_id; + QString m_prettyName; + QString m_prettyVendor; + Type m_type = Type::Software; + QUrl m_url; + bool m_required = false; + bool m_expand = false; +}; + +class LicensePage : public QWidget +{ + Q_OBJECT +public: + explicit LicensePage( QWidget* parent = nullptr ); + + void setEntries( const QList< LicenseEntry >& entriesList ); + + bool isNextEnabled() const; + +public slots: + /** @brief Check if the user can continue + * + * The user can continue if + * - none of the licenses are required, or + * - the user has ticked the "OK" box. + * This function calls updateGlobalStorage() as needed, and updates + * the appearance of the page as needed. @p checked indicates whether + * the checkbox has been ticked or not. (e.g. when @p checked is true, + * you can continue regardless) + */ + void checkAcceptance( bool checked ); + + void retranslate(); + +signals: + void nextStatusChanged( bool status ); + +private: + /** @brief Update the global storage "licenseAgree" key. */ + void updateGlobalStorage( bool v ); + + bool m_isNextEnabled; + bool m_allLicensesOptional; ///< @brief all the licenses passed to setEntries are not-required + + Ui::LicensePage* ui; + QList< LicenseWidget* > m_entries; +}; + +#endif //LICENSEPAGE_H diff --git a/calamares/src/modules/license/LicensePage.ui b/calamares/src/modules/license/LicensePage.ui new file mode 100644 index 0000000..124e65f --- /dev/null +++ b/calamares/src/modules/license/LicensePage.ui @@ -0,0 +1,172 @@ + + + +SPDX-FileCopyrightText: 2015 demmm <anke62@gmail.com> +SPDX-License-Identifier: GPL-3.0-or-later + + LicensePage + + + + 0 + 0 + 799 + 400 + + + + Form + + + + + + + + <h1>License Agreement</h1> + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + + + <Calamares license text> + + + Qt::AlignCenter + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + QFrame::NoFrame + + + Qt::ScrollBarAlwaysOn + + + Qt::ScrollBarAlwaysOff + + + true + + + + + 0 + 0 + 765 + 89 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + 0 + + + + + Qt::Horizontal + + + + 1 + 20 + + + + + + + + + + + + 0 + 0 + + + + CheckBox + + + + + + + + + + Qt::Horizontal + + + + 1 + 20 + + + + + + + + + + + + + diff --git a/calamares/src/modules/license/LicenseViewStep.cpp b/calamares/src/modules/license/LicenseViewStep.cpp new file mode 100644 index 0000000..52305c3 --- /dev/null +++ b/calamares/src/modules/license/LicenseViewStep.cpp @@ -0,0 +1,114 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Anke Boersma + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LicenseViewStep.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "LicensePage.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" + +#include + +CALAMARES_PLUGIN_FACTORY_DEFINITION( LicenseViewStepFactory, registerPlugin< LicenseViewStep >(); ) + +LicenseViewStep::LicenseViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_widget( new LicensePage ) +{ + emit nextStatusChanged( false ); + connect( m_widget, &LicensePage::nextStatusChanged, this, &LicenseViewStep::nextStatusChanged ); +} + + +LicenseViewStep::~LicenseViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +LicenseViewStep::prettyName() const +{ + return tr( "License", "@label" ); +} + + +QWidget* +LicenseViewStep::widget() +{ + return m_widget; +} + + +bool +LicenseViewStep::isNextEnabled() const +{ + return m_widget->isNextEnabled(); +} + + +bool +LicenseViewStep::isBackEnabled() const +{ + return true; +} + + +bool +LicenseViewStep::isAtBeginning() const +{ + return true; +} + + +bool +LicenseViewStep::isAtEnd() const +{ + return true; +} + + +QList< Calamares::job_ptr > +LicenseViewStep::jobs() const +{ + return QList< Calamares::job_ptr >(); +} + +void +LicenseViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + QList< LicenseEntry > entriesList; + if ( configurationMap.contains( "entries" ) + && Calamares::typeOf( configurationMap.value( "entries" ) ) == Calamares::ListVariantType ) + { + const auto entries = configurationMap.value( "entries" ).toList(); + for ( const QVariant& entryV : entries ) + { + if ( Calamares::typeOf( entryV ) != Calamares::MapVariantType ) + { + continue; + } + + LicenseEntry entry( entryV.toMap() ); + if ( entry.isValid() ) + { + entriesList.append( entry ); + } + } + } + + m_widget->setEntries( entriesList ); +} diff --git a/calamares/src/modules/license/LicenseViewStep.h b/calamares/src/modules/license/LicenseViewStep.h new file mode 100644 index 0000000..0e028f8 --- /dev/null +++ b/calamares/src/modules/license/LicenseViewStep.h @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Anke Boersma + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LICENSEPAGEPLUGIN_H +#define LICENSEPAGEPLUGIN_H + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include +#include +#include + +class LicensePage; + +class PLUGINDLLEXPORT LicenseViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit LicenseViewStep( QObject* parent = nullptr ); + ~LicenseViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + QList< Calamares::job_ptr > jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + LicensePage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( LicenseViewStepFactory ) + +#endif // LICENSEPAGEPLUGIN_H diff --git a/calamares/src/modules/license/LicenseWidget.cpp b/calamares/src/modules/license/LicenseWidget.cpp new file mode 100644 index 0000000..8f6b65e --- /dev/null +++ b/calamares/src/modules/license/LicenseWidget.cpp @@ -0,0 +1,205 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Anke Boersma + * SPDX-FileCopyrightText: 2015 Alexandre Arnt + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LicenseWidget.h" + +#include "utils/Logger.h" +#include "utils/QtCompat.h" + +#include +#include +#include +#include +#include +#include + +static QString +loadLocalFile( const QUrl& u ) +{ + if ( !u.isLocalFile() ) + { + return QString(); + } + + QFile file( u.path() ); + if ( !file.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + cWarning() << "Could not load license file" << u.path(); + return QString(); + } + + return QString( "\n" ) + file.readAll(); +} + +LicenseWidget::LicenseWidget( LicenseEntry entry, QWidget* parent ) + : QWidget( parent ) + , m_entry( std::move( entry ) ) + , m_label( new QLabel( this ) ) + , m_viewLicenseButton( new QPushButton( this ) ) + , m_licenceTextLabel( new QLabel( this ) ) + , m_isExpanded( m_entry.expandByDefault() ) +{ + QPalette pal( palette() ); + pal.setColor( WindowBackground, palette().window().color().lighter( 108 ) ); + + setObjectName( "licenseItem" ); + + setAutoFillBackground( true ); + setPalette( pal ); + setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Minimum ); + setContentsMargins( 4, 4, 4, 4 ); + + QVBoxLayout* vLayout = new QVBoxLayout; + QWidget* topPart = new QWidget( this ); + topPart->setSizePolicy( QSizePolicy::Minimum, QSizePolicy::Minimum ); + vLayout->addWidget( topPart ); + + QHBoxLayout* wiLayout = new QHBoxLayout; + topPart->setLayout( wiLayout ); + + m_label->setWordWrap( true ); + m_label->setSizePolicy( QSizePolicy::Minimum, QSizePolicy::Minimum ); + wiLayout->addWidget( m_label ); + + wiLayout->addSpacing( 10 ); + m_viewLicenseButton->setSizePolicy( QSizePolicy::Minimum, QSizePolicy::Fixed ); + wiLayout->addWidget( m_viewLicenseButton ); + + m_licenceTextLabel->setStyleSheet( "border-top: 1px solid black; margin-top: 0px; padding-top: 1em;" ); + m_licenceTextLabel->setObjectName( "licenseItemFullText" ); + + if ( m_entry.isLocal() ) + { + m_fullTextContents = loadLocalFile( m_entry.m_url ); + showLocalLicenseText(); + connect( m_viewLicenseButton, &QAbstractButton::clicked, this, &LicenseWidget::expandClicked ); + } + else + { + m_licenceTextLabel->setText( tr( "URL: %1", "@label" ).arg( m_entry.m_url.toDisplayString() ) ); + connect( m_viewLicenseButton, &QAbstractButton::clicked, this, &LicenseWidget::viewClicked ); + } + m_licenceTextLabel->setSizePolicy( QSizePolicy::Minimum, QSizePolicy::Minimum ); + + vLayout->addWidget( m_licenceTextLabel ); + setLayout( vLayout ); + + retranslateUi(); +} + +LicenseWidget::~LicenseWidget() {} + +void +LicenseWidget::retranslateUi() +{ + QString productDescription; + switch ( m_entry.m_type ) + { + case LicenseEntry::Type::Driver: + //: %1 is an untranslatable product name, example: Creative Audigy driver + productDescription = tr( "%1 driver
" + "by %2", + "@label, %1 is product name, %2 is product vendor" ) + .arg( m_entry.m_prettyName ) + .arg( m_entry.m_prettyVendor ); + break; + case LicenseEntry::Type::GpuDriver: + //: %1 is usually a vendor name, example: Nvidia graphics driver + productDescription = tr( "%1 graphics driver
" + "by %2", + "@label, %1 is product name, %2 is product vendor" ) + .arg( m_entry.m_prettyName ) + .arg( m_entry.m_prettyVendor ); + break; + case LicenseEntry::Type::BrowserPlugin: + productDescription = tr( "%1 browser plugin
" + "by %2", + "@label, %1 is product name, %2 is product vendor" ) + .arg( m_entry.m_prettyName ) + .arg( m_entry.m_prettyVendor ); + break; + case LicenseEntry::Type::Codec: + productDescription = tr( "%1 codec
" + "by %2", + "@label, %1 is product name, %2 is product vendor" ) + .arg( m_entry.m_prettyName ) + .arg( m_entry.m_prettyVendor ); + break; + case LicenseEntry::Type::Package: + productDescription = tr( "%1 package
" + "by %2", + "@label, %1 is product name, %2 is product vendor" ) + .arg( m_entry.m_prettyName ) + .arg( m_entry.m_prettyVendor ); + break; + case LicenseEntry::Type::Software: + productDescription = tr( "%1
" + "by %2", + "@label, %1 is product name, %2 is product vendor" ) + .arg( m_entry.m_prettyName ) + .arg( m_entry.m_prettyVendor ); + } + m_label->setText( productDescription ); + updateExpandToolTip(); +} + +void +LicenseWidget::showLocalLicenseText() +{ + if ( m_isExpanded ) + { + m_licenceTextLabel->setText( m_fullTextContents ); + } + else + { + QString fileName = m_entry.m_url.toDisplayString(); + if ( fileName.startsWith( "file:" ) ) + { + fileName = fileName.remove( 0, 5 ); + } + m_licenceTextLabel->setText( tr( "File: %1", "@label" ).arg( fileName ) ); + } +} + +void +LicenseWidget::expandClicked() +{ + m_isExpanded = !m_isExpanded; + // Show/hide based on the new arrow direction. + if ( !m_fullTextContents.isEmpty() ) + { + showLocalLicenseText(); + } + + updateExpandToolTip(); +} + +/** @brief Called on retranslate and when button state changes. */ +void +LicenseWidget::updateExpandToolTip() +{ + if ( m_entry.isLocal() ) + { + m_viewLicenseButton->setText( m_isExpanded ? tr( "Hide the license text", "@tooltip" ) + : tr( "Show the license text", "@tooltip" ) ); + } + else + { + m_viewLicenseButton->setText( tr( "Open the license agreement in browser", "@tooltip" ) ); + } +} + +void +LicenseWidget::viewClicked() +{ + QDesktopServices::openUrl( m_entry.m_url ); +} diff --git a/calamares/src/modules/license/LicenseWidget.h b/calamares/src/modules/license/LicenseWidget.h new file mode 100644 index 0000000..eb7d8ed --- /dev/null +++ b/calamares/src/modules/license/LicenseWidget.h @@ -0,0 +1,45 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Anke Boersma + * SPDX-FileCopyrightText: 2015 Alexandre Arnt + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LICENSE_LICENSEWIDGET_H +#define LICENSE_LICENSEWIDGET_H + +#include "LicensePage.h" + +#include +#include + +class QPushButton; + +class LicenseWidget : public QWidget +{ + Q_OBJECT +public: + LicenseWidget( LicenseEntry e, QWidget* parent = nullptr ); + ~LicenseWidget() override; + + void retranslateUi(); + +private: + void showLocalLicenseText(); // Display (or hide) the local license text + void expandClicked(); // "slot" to toggle show/hide of local license text + void viewClicked(); // "slot" to open link + void updateExpandToolTip(); + + LicenseEntry m_entry; + QLabel* m_label; + QPushButton* m_viewLicenseButton; + QLabel* m_licenceTextLabel; + QString m_fullTextContents; + bool m_isExpanded; +}; +#endif diff --git a/calamares/src/modules/license/README.md b/calamares/src/modules/license/README.md new file mode 100644 index 0000000..aaeb480 --- /dev/null +++ b/calamares/src/modules/license/README.md @@ -0,0 +1,24 @@ +### License Approval Module + + + +For distributions shipping proprietary software, this module creates a +Global Storage entry when the user accepts or declines one or more of +the End User License Agreements files that are presented here. + +The number of licenses shown are configurable. The `license.conf` file +has a few examples of how to add URLs. + +If you do not want to include this module in your Calamares build, +add `-DSKIP_MODULES="license"` to your build settings (CMake call). + +How to implement the removal or not installing of proprietary software is +up to any distribution to implement. For example, proprietary graphics +drivers cannot simply be removed in the packages module, a free version +will need to be installed. + +An example of where the licenseAgree globalstorage entry is used: +https://codeberg.org/KaOS/calamares/raw/branch/master/src/modules/nonfree_drivers/main.py diff --git a/calamares/src/modules/license/license.conf b/calamares/src/modules/license/license.conf new file mode 100644 index 0000000..e32d499 --- /dev/null +++ b/calamares/src/modules/license/license.conf @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration file for License viewmodule, Calamares +# Syntax is YAML 1.2 +--- +# Define a list of licenses which may / must be accepted before continuing. +# +# Each entry in this list has the following keys: +# - id Entry identifier, must be unique. Not user visible. YAML: string. +# - name Pretty name for the software product, user visible and untranslatable. YAML: string. +# - vendor Pretty name for the software vendor, user visible and untranslatable. YAML: string, optional, default is empty. +# - type Package type identifier for presentation, not user visible but affects user visible strings. YAML: string. +# values: driver, gpudriver, browserplugin, codec, package, software; optional, default is software. +# - required If set to true, the user cannot proceed without accepting this license. YAML: boolean, optional, default is false. +# - url A URL for the license; a remote URL is not shown in Calamares, but a link +# to the URL is provided, which opens in the default web browser. A local +# URL (i.e. file:///) assumes that the contents are HTML or plain text, and +# displays the license in-line. YAML: string, mandatory. +# - expand A boolean value only relevant for **local** URLs. If true, +# the license text is displayed in "expanded" form by +# default, rather than requiring the user to first open it up. +# YAML: boolean, optional, default is false. +entries: +- id: nvidia + name: Nvidia + vendor: Nvidia Corporation + type: driver + url: http://developer.download.nvidia.com/cg/Cg_3.0/license.pdf + required: false +- id: amd + name: Catalyst + vendor: "Advanced Micro Devices, Inc." + type: gpudriver + url: http://support.amd.com/en-us/download/eula + required: false +- id: flashplugin + name: Adobe Flash + vendor: Adobe Systems Incorporated + type: browserplugin + url: http://www.adobe.com/products/eulas/pdfs/PlatformClients_PC_WWEULA_Combined_20100108_1657.pdf + required: true +# This example uses a file: link. This example uses a relative link, which +# is relative to where you run Calamares. Assuming you run it from build/ +# as part of your testing, you'll get the LICENSE text for Calamares +# (which is the text of the GPLv3, not proprietary at all). +- id: mine_mine + name: Calamares Proprietary License + vendor: Calamares, Inc. + type: software + required: true + url: file:../LICENSE + expand: true diff --git a/calamares/src/modules/license/license.schema.yaml b/calamares/src/modules/license/license.schema.yaml new file mode 100644 index 0000000..d933ac1 --- /dev/null +++ b/calamares/src/modules/license/license.schema.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/license +additionalProperties: false +type: object +properties: + entries: + type: array + items: + type: object + additionalProperties: false + properties: + id: { type: string } + name: { type: string } + vendor: { type: string } + type: { type: string } + url: { type: string } + required: { type: boolean, default: false } + expand: { type: boolean, default: false } diff --git a/calamares/src/modules/locale/CMakeLists.txt b/calamares/src/modules/locale/CMakeLists.txt new file mode 100644 index 0000000..1cb7a4f --- /dev/null +++ b/calamares/src/modules/locale/CMakeLists.txt @@ -0,0 +1,50 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# When debugging the timezone widget, add this debugging definition +# to have a debugging-friendly timezone widget, debug logging, +# and no intrusive timezone-setting while clicking around. +option(DEBUG_TIMEZONES "Debug-friendly timezone widget." OFF) + +include_directories(${PROJECT_BINARY_DIR}/src/libcalamaresui) + +calamares_add_plugin(locale + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + LCLocaleDialog.cpp + LocaleConfiguration.cpp + LocaleNames.cpp + LocalePage.cpp + LocaleViewStep.cpp + SetTimezoneJob.cpp + timezonewidget/timezonewidget.cpp + timezonewidget/TimeZoneImage.cpp + UI + RESOURCES + locale.qrc + LINK_PRIVATE_LIBRARIES + ${qtname}::Network + yamlcpp::yamlcpp + SHARED_LIB +) +if(DEBUG_TIMEZONES) + target_compile_definitions(${locale_TARGET} PRIVATE DEBUG_TIMEZONES) +endif() + +calamares_add_test( + localetest + SOURCES + Tests.cpp + Config.cpp + LocaleConfiguration.cpp + LocaleNames.cpp + SetTimezoneJob.cpp + timezonewidget/TimeZoneImage.cpp + DEFINITIONS SOURCE_DIR="${CMAKE_CURRENT_LIST_DIR}/images" DEBUG_TIMEZONES=1 + LIBRARIES ${qtname}::Gui +) diff --git a/calamares/src/modules/locale/Config.cpp b/calamares/src/modules/locale/Config.cpp new file mode 100644 index 0000000..3e27a52 --- /dev/null +++ b/calamares/src/modules/locale/Config.cpp @@ -0,0 +1,601 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "SetTimezoneJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "locale/Global.h" +#include "locale/Translation.h" +#include "modulesystem/ModuleManager.h" +#include "network/Manager.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include +#include +#include + +/** @brief Load supported locale keys + * + * If i18n/SUPPORTED exists, read the lines from that and return those + * as supported locales; otherwise, try the file at @p localeGenPath + * and get lines from that. Failing both, try the output of `locale -a`. + * + * This gives us a list of locale identifiers (e.g. en_US.UTF-8), which + * are not particularly human-readable. + * + * Only UTF-8 locales are returned (even if the system claims to support + * other, non-UTF-8, locales). + */ +static QStringList +loadLocales( const QString& localeGenPath ) +{ + Logger::Once o; + QStringList localeGenLines; + + // Some distros come with a meaningfully commented and easy to parse locale.gen, + // and others ship a separate file /usr/share/i18n/SUPPORTED with a clean list of + // supported locales. We first try that one, and if it doesn't exist, we fall back + // to parsing the lines from locale.gen + localeGenLines.clear(); + QFile supported( "/usr/share/i18n/SUPPORTED" ); + QByteArray ba; + + if ( supported.exists() && supported.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + cDebug() << o << "Loading locales from" << supported.fileName(); + ba = supported.readAll(); + supported.close(); + + const auto lines = ba.split( '\n' ); + for ( const QByteArray& line : lines ) + { + localeGenLines.append( QString::fromLatin1( line.simplified() ) ); + } + } + else + { + QFile localeGen( localeGenPath ); + if ( localeGen.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + cDebug() << o << "Loading locales from" << localeGenPath; + ba = localeGen.readAll(); + localeGen.close(); + } + else + { + cWarning() << "Cannot open file" << localeGenPath + << ". Assuming the supported languages are already built into " + "the locale archive."; + QProcess localeA; + localeA.start( "locale", QStringList() << "-a" ); + localeA.waitForFinished(); + ba = localeA.readAllStandardOutput(); + } + const auto lines = ba.split( '\n' ); + for ( const QByteArray& line : lines ) + { + if ( line.startsWith( "## " ) || line.startsWith( "# " ) || line.simplified() == "#" ) + { + continue; + } + + QString lineString = QString::fromLatin1( line.simplified() ); + if ( lineString.startsWith( "#" ) ) + { + lineString.remove( '#' ); + } + lineString = lineString.simplified(); + + if ( lineString.isEmpty() ) + { + continue; + } + + localeGenLines.append( lineString ); + } + } + + if ( localeGenLines.isEmpty() ) + { + cWarning() << "cannot acquire a list of available locales." + << "The locale and localecfg modules will be broken as long as this " + "system does not provide" + << "\n\t " + << "* a well-formed" << supported.fileName() << "\n\tOR" + << "* a well-formed" + << ( localeGenPath.isEmpty() ? QLatin1String( "/etc/locale.gen" ) : localeGenPath ) << "\n\tOR" + << "* a complete pre-compiled locale-gen database which allows complete locale -a output."; + return localeGenLines; // something went wrong and there's nothing we can do about it. + } + else + { + cDebug() << o << "Read" << localeGenLines.length() << "lines"; + } + + // Assuming we have a list of supported locales, we usually only want UTF-8 ones + // because it's not 1995. + auto notUtf8 = []( const QString& s ) + { return !s.contains( "UTF-8", Qt::CaseInsensitive ) && !s.contains( "utf8", Qt::CaseInsensitive ); }; + auto it = std::remove_if( localeGenLines.begin(), localeGenLines.end(), notUtf8 ); + localeGenLines.erase( it, localeGenLines.end() ); + + // We strip " UTF-8" from "en_US.UTF-8 UTF-8" because it's redundant redundant. + // Also simplify whitespace. + auto unredundant = []( QString& s ) + { + if ( s.endsWith( " UTF-8" ) ) + { + s.chop( 6 ); + } + s = s.simplified(); + }; + std::for_each( localeGenLines.begin(), localeGenLines.end(), unredundant ); + cDebug() << o << "After filtering" << localeGenLines.length() << "lines"; + + return localeGenLines; +} + +static bool +updateGSLocation( Calamares::GlobalStorage* gs, const Calamares::Locale::TimeZoneData* location ) +{ + const QString regionKey = QStringLiteral( "locationRegion" ); + const QString zoneKey = QStringLiteral( "locationZone" ); + + if ( !location ) + { + if ( gs->contains( regionKey ) || gs->contains( zoneKey ) ) + { + gs->remove( regionKey ); + gs->remove( zoneKey ); + return true; + } + return false; + } + + // Update the GS region and zone (and possibly the live timezone) + bool locationChanged + = ( location->region() != gs->value( regionKey ) ) || ( location->zone() != gs->value( zoneKey ) ); + + gs->insert( regionKey, location->region() ); + gs->insert( zoneKey, location->zone() ); + + return locationChanged; +} + +static void +updateGSLocale( Calamares::GlobalStorage* gs, const LocaleConfiguration& locale ) +{ + Calamares::Locale::insertGS( *gs, locale.toMap(), Calamares::Locale::InsertMode::Overwrite ); +} + +Config::Config( QObject* parent ) + : QObject( parent ) + , m_regionModel( std::make_unique< Calamares::Locale::RegionsModel >() ) + , m_zonesModel( std::make_unique< Calamares::Locale::ZonesModel >() ) + , m_regionalZonesModel( std::make_unique< Calamares::Locale::RegionalZonesModel >( m_zonesModel.get() ) ) +{ + // Slightly unusual: connect to our *own* signals. Wherever the language + // or the location is changed, these signals are emitted, so hook up to + // them to update global storage accordingly. This simplifies code: + // we don't need to call an update-GS method, or introduce an intermediate + // update-thing-and-GS method. And everywhere where we **do** change + // language or location, we already emit the signal. + connect( this, + &Config::currentLanguageCodeChanged, + [ & ]() + { + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + gs->insert( "locale", m_selectedLocaleConfiguration.toBcp47() ); + } ); + + connect( this, + &Config::currentLCCodeChanged, + [ & ]() { updateGSLocale( Calamares::JobQueue::instance()->globalStorage(), localeConfiguration() ); } ); + + connect( this, + &Config::currentLocationChanged, + [ & ]() + { + const bool locationChanged + = updateGSLocation( Calamares::JobQueue::instance()->globalStorage(), currentLocation() ); + + if ( locationChanged && m_adjustLiveTimezone ) + { + QProcess::execute( "timedatectl", // depends on systemd + { "set-timezone", currentTimezoneCode() } ); + } + + emit currentTimezoneCodeChanged( currentTimezoneCode() ); + emit currentTimezoneNameChanged( currentTimezoneName() ); + } ); + + auto prettyStatusNotify = [ & ]() { emit prettyStatusChanged( prettyStatus() ); }; + connect( this, &Config::currentLanguageStatusChanged, prettyStatusNotify ); + connect( this, &Config::currentLCStatusChanged, prettyStatusNotify ); + connect( this, &Config::currentLocationStatusChanged, prettyStatusNotify ); +} + +Config::~Config() {} + +void +Config::setCurrentLocation() +{ + if ( !m_currentLocation && m_startingTimezone.isValid() ) + { + setCurrentLocation( m_startingTimezone ); + } + if ( !m_selectedLocaleConfiguration.explicit_lang ) + { + auto newLocale = automaticLocaleConfiguration(); + setLanguage( newLocale.language() ); + } +} + +void +Config::setCurrentLocation( const QString& regionzone ) +{ + auto r = Calamares::GeoIP::splitTZString( regionzone ); + if ( r.isValid() ) + { + setCurrentLocation( r ); + } +} + +void +Config::setCurrentLocation( const Calamares::GeoIP::RegionZonePair& tz ) +{ + setCurrentLocation( tz.region(), tz.zone() ); +} + +void +Config::setCurrentLocation( const QString& regionName, const QString& zoneName ) +{ + using namespace Calamares::Locale; + auto* zone = m_zonesModel->find( regionName, zoneName ); + if ( zone ) + { + setCurrentLocation( zone ); + } + else + { + // Recursive, but America/New_York always exists. + setCurrentLocation( QStringLiteral( "America" ), QStringLiteral( "New_York" ) ); + } +} + +void +Config::setCurrentLocation( const Calamares::Locale::TimeZoneData* location ) +{ + const bool updateLocation = ( location != m_currentLocation ); + if ( updateLocation ) + { + m_currentLocation = location; + } + + // lang should be always be updated + auto newLocale = automaticLocaleConfiguration(); + if ( !m_selectedLocaleConfiguration.explicit_lang ) + { + setLanguage( newLocale.language() ); + } + + if ( updateLocation ) + { + if ( !m_selectedLocaleConfiguration.explicit_lc ) + { + m_selectedLocaleConfiguration.lc_numeric = newLocale.lc_numeric; + m_selectedLocaleConfiguration.lc_time = newLocale.lc_time; + m_selectedLocaleConfiguration.lc_monetary = newLocale.lc_monetary; + m_selectedLocaleConfiguration.lc_paper = newLocale.lc_paper; + m_selectedLocaleConfiguration.lc_name = newLocale.lc_name; + m_selectedLocaleConfiguration.lc_address = newLocale.lc_address; + m_selectedLocaleConfiguration.lc_telephone = newLocale.lc_telephone; + m_selectedLocaleConfiguration.lc_measurement = newLocale.lc_measurement; + m_selectedLocaleConfiguration.lc_identification = newLocale.lc_identification; + + emit currentLCStatusChanged( currentLCStatus() ); + } + emit currentLocationChanged( m_currentLocation ); + // Other signals come from the LocationChanged signal + } +} + +LocaleConfiguration +Config::automaticLocaleConfiguration() const +{ + // Special case: no location has been set at **all** + if ( !currentLocation() ) + { + return LocaleConfiguration(); + } + + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + QString lang = Calamares::Locale::readGS( *gs, QStringLiteral( "LANG" ) ); + if ( lang.isEmpty() ) + { + lang = QLocale().name(); + } + return LocaleConfiguration::fromLanguageAndLocation( lang, supportedLocales(), currentLocation()->country() ); +} + +LocaleConfiguration +Config::localeConfiguration() const +{ + return m_selectedLocaleConfiguration.isEmpty() ? automaticLocaleConfiguration() : m_selectedLocaleConfiguration; +} + +void +Config::setLanguageExplicitly( const QString& language ) +{ + m_selectedLocaleConfiguration.explicit_lang = true; + setLanguage( language ); +} + +void +Config::setLanguage( const QString& language ) +{ + if ( language != m_selectedLocaleConfiguration.language() ) + { + m_selectedLocaleConfiguration.setLanguage( language ); + + emit currentLanguageStatusChanged( currentLanguageStatus() ); + emit currentLanguageCodeChanged( currentLanguageCode() ); + } +} + +void +Config::setLCLocaleExplicitly( const QString& locale ) +{ + // TODO: improve the granularity of this setting. + m_selectedLocaleConfiguration.lc_numeric = locale; + m_selectedLocaleConfiguration.lc_time = locale; + m_selectedLocaleConfiguration.lc_monetary = locale; + m_selectedLocaleConfiguration.lc_paper = locale; + m_selectedLocaleConfiguration.lc_name = locale; + m_selectedLocaleConfiguration.lc_address = locale; + m_selectedLocaleConfiguration.lc_telephone = locale; + m_selectedLocaleConfiguration.lc_measurement = locale; + m_selectedLocaleConfiguration.lc_identification = locale; + m_selectedLocaleConfiguration.explicit_lc = true; + + emit currentLCStatusChanged( currentLCStatus() ); + emit currentLCCodeChanged( currentLCCode() ); +} + +QString +Config::currentLocationStatus() const +{ + if ( m_currentLocation ) + { + return tr( "Set timezone to %1.", "@action" ).arg( currentTimezoneName() ); + } + return QString(); +} + +QString +Config::currentTimezoneCode() const +{ + if ( m_currentLocation ) + { + return m_currentLocation->region() + '/' + m_currentLocation->zone(); + } + return QString(); +} + +QString +Config::currentTimezoneName() const +{ + if ( m_currentLocation ) + { + return m_regionModel->translated( m_currentLocation->region() ) + '/' + m_currentLocation->translated(); + } + return QString(); +} + +static inline QString +localeLabel( const QString& s ) +{ + using Calamares::Locale::Translation; + + Translation lang( { s }, Translation::LabelFormat::AlwaysWithCountry ); + return lang.label(); +} + +QString +Config::currentLanguageStatus() const +{ + return tr( "The system language will be set to %1.", "@info" ) + .arg( localeLabel( m_selectedLocaleConfiguration.language() ) ); +} + +QString +Config::currentLCStatus() const +{ + return tr( "The numbers and dates locale will be set to %1.", "@info" ) + .arg( localeLabel( m_selectedLocaleConfiguration.lc_numeric ) ); +} + +QString +Config::prettyStatus() const +{ + QStringList l { currentLocationStatus(), currentLanguageStatus(), currentLCStatus() }; + return l.join( QStringLiteral( "
" ) ); +} + +static inline void +getLocaleGenLines( const QVariantMap& configurationMap, QStringList& localeGenLines ) +{ + QString localeGenPath = Calamares::getString( configurationMap, "localeGenPath" ); + if ( localeGenPath.isEmpty() ) + { + localeGenPath = QStringLiteral( "/etc/locale.gen" ); + } + localeGenLines = loadLocales( localeGenPath ); +} + +static inline void +getAdjustLiveTimezone( const QVariantMap& configurationMap, bool& adjustLiveTimezone ) +{ + adjustLiveTimezone + = Calamares::getBool( configurationMap, "adjustLiveTimezone", Calamares::Settings::instance()->doChroot() ); +#ifdef DEBUG_TIMEZONES + if ( adjustLiveTimezone ) + { + cWarning() << "Turning off live-timezone adjustments because debugging is on."; + adjustLiveTimezone = false; + } +#endif +#ifdef __FreeBSD__ + if ( adjustLiveTimezone ) + { + cWarning() << "Turning off live-timezone adjustments on FreeBSD."; + adjustLiveTimezone = false; + } +#endif +} + +static inline void +getStartingTimezone( const QVariantMap& configurationMap, Calamares::GeoIP::RegionZonePair& startingTimezone ) +{ + QString region = Calamares::getString( configurationMap, "region" ); + QString zone = Calamares::getString( configurationMap, "zone" ); + if ( !region.isEmpty() && !zone.isEmpty() ) + { + startingTimezone = Calamares::GeoIP::RegionZonePair( region, zone ); + } + else + { + startingTimezone + = Calamares::GeoIP::RegionZonePair( QStringLiteral( "America" ), QStringLiteral( "New_York" ) ); + } + + if ( Calamares::getBool( configurationMap, "useSystemTimezone", false ) ) + { + auto systemtz = Calamares::GeoIP::splitTZString( QTimeZone::systemTimeZoneId() ); + if ( systemtz.isValid() ) + { + cDebug() << "Overriding configured timezone" << startingTimezone << "with system timezone" << systemtz; + startingTimezone = systemtz; + } + } +} + +static inline void +getGeoIP( const QVariantMap& configurationMap, std::unique_ptr< Calamares::GeoIP::Handler >& geoip ) +{ + bool ok = false; + QVariantMap map = Calamares::getSubMap( configurationMap, "geoip", ok ); + if ( ok ) + { + QString url = Calamares::getString( map, "url" ); + QString style = Calamares::getString( map, "style" ); + QString selector = Calamares::getString( map, "selector" ); + + geoip = std::make_unique< Calamares::GeoIP::Handler >( style, url, selector ); + if ( !geoip->isValid() ) + { + cWarning() << "GeoIP Style" << style << "is not recognized."; + } + } +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_originalTimezone = Calamares::GeoIP::splitTZString( QTimeZone::systemTimeZoneId() ); + + getLocaleGenLines( configurationMap, m_localeGenLines ); + getAdjustLiveTimezone( configurationMap, m_adjustLiveTimezone ); + getStartingTimezone( configurationMap, m_startingTimezone ); + getGeoIP( configurationMap, m_geoip ); + +#ifndef BUILD_AS_TEST + if ( m_geoip && m_geoip->isValid() ) + { + connect( + Calamares::ModuleManager::instance(), &Calamares::ModuleManager::modulesLoaded, this, &Config::startGeoIP ); + } +#endif +} + +Calamares::JobList +Config::createJobs() +{ + Calamares::JobList list; + const auto* location = currentLocation(); + + if ( location ) + { + Calamares::Job* j = new SetTimezoneJob( location->region(), location->zone() ); + list.append( Calamares::job_ptr( j ) ); + } + + return list; +} + +void +Config::finalizeGlobalStorage() const +{ + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + updateGSLocale( gs, localeConfiguration() ); + updateGSLocation( gs, currentLocation() ); +} + +void +Config::startGeoIP() +{ + if ( m_geoip && m_geoip->isValid() ) + { + Calamares::Network::Manager network; + if ( network.hasInternet() || network.synchronousPing( m_geoip->url() ) ) + { + using Watcher = QFutureWatcher< Calamares::GeoIP::RegionZonePair >; + m_geoipWatcher = std::make_unique< Watcher >(); + m_geoipWatcher->setFuture( m_geoip->query() ); + connect( m_geoipWatcher.get(), &Watcher::finished, this, &Config::completeGeoIP ); + } + } +} + +void +Config::completeGeoIP() +{ + if ( !currentLocation() ) + { + auto r = m_geoipWatcher->result(); + if ( r.isValid() ) + { + m_startingTimezone = r; + } + else + { + cWarning() << "GeoIP returned invalid result."; + } + } + else + { + cWarning() << "GeoIP result ignored because a location is already set."; + } + m_geoipWatcher.reset(); + m_geoip.reset(); +} + +void +Config::cancel() +{ + if ( m_adjustLiveTimezone && m_originalTimezone.isValid() ) + { + QProcess::execute( "timedatectl", { "set-timezone", m_originalTimezone.asString() } ); + } +} diff --git a/calamares/src/modules/locale/Config.h b/calamares/src/modules/locale/Config.h new file mode 100644 index 0000000..fb1fae8 --- /dev/null +++ b/calamares/src/modules/locale/Config.h @@ -0,0 +1,198 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LOCALE_CONFIG_H +#define LOCALE_CONFIG_H + +#include "LocaleConfiguration.h" + +#include "Job.h" +#include "geoip/Handler.h" +#include "geoip/Interface.h" +#include "locale/TimeZone.h" + +#include +#include + +#include + +class Config : public QObject +{ + Q_OBJECT + Q_PROPERTY( const QStringList& supportedLocales READ supportedLocales CONSTANT FINAL ) + Q_PROPERTY( Calamares::Locale::RegionsModel* regionModel READ regionModel CONSTANT FINAL ) + Q_PROPERTY( Calamares::Locale::ZonesModel* zonesModel READ zonesModel CONSTANT FINAL ) + Q_PROPERTY( QAbstractItemModel* regionalZonesModel READ regionalZonesModel CONSTANT FINAL ) + + Q_PROPERTY( Calamares::Locale::TimeZoneData* currentLocation READ currentLocation_c NOTIFY currentLocationChanged ) + + // Status are complete, human-readable, messages + Q_PROPERTY( QString currentLocationStatus READ currentLocationStatus NOTIFY currentLanguageStatusChanged ) + Q_PROPERTY( QString currentLanguageStatus READ currentLanguageStatus NOTIFY currentLanguageStatusChanged ) + Q_PROPERTY( QString currentLCStatus READ currentLCStatus NOTIFY currentLCStatusChanged ) + // Name are shorter human-readable names + // .. main difference is that status is a full sentence, like "Timezone is America/New York" + // while name is just "America/New York" (and the code, below, is "America/New_York") + Q_PROPERTY( QString currentTimezoneName READ currentTimezoneName NOTIFY currentTimezoneNameChanged ) + // Code are internal identifiers, like "en_US.UTF-8" + Q_PROPERTY( QString currentTimezoneCode READ currentTimezoneCode NOTIFY currentTimezoneCodeChanged ) + Q_PROPERTY( QString currentLanguageCode READ currentLanguageCode WRITE setLanguageExplicitly NOTIFY + currentLanguageCodeChanged ) + Q_PROPERTY( QString currentLCCode READ currentLCCode WRITE setLCLocaleExplicitly NOTIFY currentLCCodeChanged ) + + // This is a long human-readable string with all three statuses + Q_PROPERTY( QString prettyStatus READ prettyStatus NOTIFY prettyStatusChanged FINAL ) + +public: + Config( QObject* parent = nullptr ); + ~Config() override; + + void setConfigurationMap( const QVariantMap& ); + void finalizeGlobalStorage() const; + Calamares::JobList createJobs(); + + /// locale configuration (LC_* and LANG) based solely on the current location. + LocaleConfiguration automaticLocaleConfiguration() const; + /// locale configuration that takes explicit settings into account + LocaleConfiguration localeConfiguration() const; + + /// The human-readable description of what timezone is used + QString currentLocationStatus() const; + /// The human-readable description of what language is used + QString currentLanguageStatus() const; + /// The human-readable description of what locale (LC_*) is used + QString currentLCStatus() const; + + /// The human-readable summary of what the module will do + QString prettyStatus() const; + + // A long list of locale codes (e.g. en_US.UTF-8) + const QStringList& supportedLocales() const { return m_localeGenLines; } + // All the regions (Africa, America, ...) + Calamares::Locale::RegionsModel* regionModel() const { return m_regionModel.get(); } + // All of the timezones in the world, according to zone.tab + Calamares::Locale::ZonesModel* zonesModel() const { return m_zonesModel.get(); } + // This model can be filtered by region + Calamares::Locale::RegionalZonesModel* regionalZonesModel() const { return m_regionalZonesModel.get(); } + + const Calamares::Locale::TimeZoneData* currentLocation() const { return m_currentLocation; } + + /// Special case, set location from starting timezone if not already set + void setCurrentLocation(); + + /// Restores original timezone, if any + void cancel(); + +private: + Calamares::Locale::TimeZoneData* currentLocation_c() const + { + return const_cast< Calamares::Locale::TimeZoneData* >( m_currentLocation ); + } + +public Q_SLOTS: + /// Set the language, but do not mark it as user-choice + void setLanguage( const QString& language ); + /// Set a language by user-choice, overriding future location changes + void setLanguageExplicitly( const QString& language ); + /// Set LC (formats) by user-choice, overriding future location changes + void setLCLocaleExplicitly( const QString& locale ); + + /** @brief Sets a location by full name + * + * @p regionzone should be an identifier from zone.tab, e.g. "Africa/Abidjan", + * which is split into regon and zone. Invalid names will **not** + * change the actual location. + */ + void setCurrentLocation( const QString& regionzone ); + + /** @brief Sets a location by split name + * + * @p region should be "America" or the like, while @p zone + * names a zone within that region. + */ + void setCurrentLocation( const QString& region, const QString& zone ); + + /** @brief Sets a location by strongly-typed region+zone name */ + void setCurrentLocation( const Calamares::GeoIP::RegionZonePair& tz ); + + /** @brief Sets a location by pointer to zone data. + * + */ + void setCurrentLocation( const Calamares::Locale::TimeZoneData* tz ); + + QString currentLanguageCode() const { return localeConfiguration().language(); } + QString currentLCCode() const { return localeConfiguration().lc_numeric; } + QString currentTimezoneName() const; // human-readable + QString currentTimezoneCode() const; + +signals: + void currentLocationChanged( const Calamares::Locale::TimeZoneData* location ) const; + void currentLocationStatusChanged( const QString& ) const; + void currentLanguageStatusChanged( const QString& ) const; + void currentLCStatusChanged( const QString& ) const; + void prettyStatusChanged( const QString& ) const; + void currentLanguageCodeChanged( const QString& ) const; + void currentLCCodeChanged( const QString& ) const; + void currentTimezoneCodeChanged( const QString& ) const; + void currentTimezoneNameChanged( const QString& ) const; + +private: + /// A list of supported locale identifiers (e.g. "en_US.UTF-8") + QStringList m_localeGenLines; + + /// The regions (America, Asia, Europe ..) + std::unique_ptr< Calamares::Locale::RegionsModel > m_regionModel; + std::unique_ptr< Calamares::Locale::ZonesModel > m_zonesModel; + std::unique_ptr< Calamares::Locale::RegionalZonesModel > m_regionalZonesModel; + + const Calamares::Locale::TimeZoneData* m_currentLocation = nullptr; + + /** @brief Specific locale configurations + * + * "Automatic" locale configuration based on the location (e.g. + * Europe/Amsterdam means Dutch language and Dutch locale) leave + * this empty; if the user explicitly sets something, then + * this configuration is non-empty and takes precedence over the + * automatic location settings (so a user in Amsterdam can still + * pick Ukranian settings, for instance). + */ + LocaleConfiguration m_selectedLocaleConfiguration; + + /** @brief Should we adjust the "live" timezone when the location changes? + * + * In the Widgets UI, clicking around on the world map adjusts the + * timezone, and the live system can be made to follow that. + */ + bool m_adjustLiveTimezone; + + /** @brief The initial timezone (region, zone) specified in the config. + * + * This may be overridden by setting *useSystemTimezone* or by + * GeoIP settings. + */ + Calamares::GeoIP::RegionZonePair m_startingTimezone; + + /// @brief The timezone set in the system when Calamares started (not from config) + Calamares::GeoIP::RegionZonePair m_originalTimezone; + + /** @brief Handler for GeoIP lookup (if configured) + * + * The GeoIP lookup needs to be started at some suitable time, + * by explicitly calling *TODO* + */ + std::unique_ptr< Calamares::GeoIP::Handler > m_geoip; + + // Implementation details for doing GeoIP lookup + void startGeoIP(); + void completeGeoIP(); + std::unique_ptr< QFutureWatcher< Calamares::GeoIP::RegionZonePair > > m_geoipWatcher; +}; + + +#endif diff --git a/calamares/src/modules/locale/LCLocaleDialog.cpp b/calamares/src/modules/locale/LCLocaleDialog.cpp new file mode 100644 index 0000000..a1660e0 --- /dev/null +++ b/calamares/src/modules/locale/LCLocaleDialog.cpp @@ -0,0 +1,90 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LCLocaleDialog.h" + +#include +#include +#include +#include +#include + +LCLocaleDialog::LCLocaleDialog( const QString& guessedLCLocale, const QStringList& localeGenLines, QWidget* parent ) + : QDialog( parent ) +{ + setModal( true ); + setWindowTitle( tr( "System Locale Setting", "@title" ) ); + + QBoxLayout* mainLayout = new QVBoxLayout; + setLayout( mainLayout ); + + QLabel* upperText = new QLabel( this ); + upperText->setWordWrap( true ); + upperText->setText( tr( "The system locale setting affects the language and character " + "set for some command line user interface elements.
" + "The current setting is %1.", + "@info" ) + .arg( guessedLCLocale ) ); + mainLayout->addWidget( upperText ); + setMinimumWidth( upperText->fontMetrics().height() * 24 ); + + m_localesWidget = new QListWidget( this ); + m_localesWidget->addItems( localeGenLines ); + m_localesWidget->setSelectionMode( QAbstractItemView::SingleSelection ); + mainLayout->addWidget( m_localesWidget ); + + int selected = -1; + for ( int i = 0; i < localeGenLines.count(); ++i ) + { + if ( localeGenLines[ i ].contains( guessedLCLocale ) ) + { + selected = i; + break; + } + } + + QDialogButtonBox* dbb + = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, this ); + dbb->button( QDialogButtonBox::Cancel )->setText( tr( "&Cancel", "@button" ) ); + dbb->button( QDialogButtonBox::Ok )->setText( tr( "&OK", "@button" ) ); + + mainLayout->addWidget( dbb ); + + connect( dbb->button( QDialogButtonBox::Ok ), &QPushButton::clicked, this, &QDialog::accept ); + connect( dbb->button( QDialogButtonBox::Cancel ), &QPushButton::clicked, this, &QDialog::reject ); + + connect( m_localesWidget, &QListWidget::itemDoubleClicked, this, &QDialog::accept ); + connect( m_localesWidget, + &QListWidget::itemSelectionChanged, + [ this, dbb ]() + { + if ( m_localesWidget->selectedItems().isEmpty() ) + { + dbb->button( QDialogButtonBox::Ok )->setEnabled( false ); + } + else + { + dbb->button( QDialogButtonBox::Ok )->setEnabled( true ); + } + } ); + + if ( selected > -1 ) + { + m_localesWidget->setCurrentRow( selected ); + } +} + + +QString +LCLocaleDialog::selectedLCLocale() +{ + const auto items = m_localesWidget->selectedItems(); + return items.isEmpty() ? QString {} : items.first()->text(); +} diff --git a/calamares/src/modules/locale/LCLocaleDialog.h b/calamares/src/modules/locale/LCLocaleDialog.h new file mode 100644 index 0000000..2d1869a --- /dev/null +++ b/calamares/src/modules/locale/LCLocaleDialog.h @@ -0,0 +1,31 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LCLOCALEDIALOG_H +#define LCLOCALEDIALOG_H + +#include + +class QListWidget; + +class LCLocaleDialog : public QDialog +{ + Q_OBJECT +public: + explicit LCLocaleDialog( const QString& guessedLCLocale, + const QStringList& localeGenLines, + QWidget* parent = nullptr ); + + QString selectedLCLocale(); + +private: + QListWidget* m_localesWidget; +}; + +#endif // LCLOCALEDIALOG_H diff --git a/calamares/src/modules/locale/LocaleConfiguration.cpp b/calamares/src/modules/locale/LocaleConfiguration.cpp new file mode 100644 index 0000000..c62b1ab --- /dev/null +++ b/calamares/src/modules/locale/LocaleConfiguration.cpp @@ -0,0 +1,336 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LocaleConfiguration.h" +#include "LocaleNames.h" + +#include "utils/Logger.h" + +#include +#include +#include + +LocaleConfiguration::LocaleConfiguration() + : explicit_lang( false ) + , explicit_lc( false ) +{ +} + + +LocaleConfiguration::LocaleConfiguration( const QString& localeName, const QString& formatsName ) + : LocaleConfiguration() +{ + lc_numeric = lc_time = lc_monetary = lc_paper = lc_name = lc_address = lc_telephone = lc_measurement + = lc_identification = formatsName; + + setLanguage( localeName ); +} + + +void +LocaleConfiguration::setLanguage( const QString& localeName ) +{ + QString language = localeName.split( '_' ).first(); + m_languageLocaleBcp47 = QLocale( language ).bcp47Name().toLower(); + m_lang = localeName; +} + +static LocaleNameParts +updateCountry( LocaleNameParts p, const QString& country ) +{ + p.country = country; + return p; +} + +static QPair< int, LocaleNameParts > +identifyBestLanguageMatch( const LocaleNameParts& referenceLocale, QVector< LocaleNameParts >& others ) +{ + std::sort( others.begin(), + others.end(), + [ & ]( const LocaleNameParts& lhs, const LocaleNameParts& rhs ) + { return referenceLocale.similarity( lhs ) < referenceLocale.similarity( rhs ); } ); + // The best match is at the end + LocaleNameParts best_match = others.last(); + if ( !( referenceLocale.similarity( best_match ) > LocaleNameParts::no_match ) ) + { + cDebug() << Logger::SubEntry << "Got no good match for" << referenceLocale.name(); + return { LocaleNameParts::no_match, LocaleNameParts {} }; + } + else + { + cDebug() << Logger::SubEntry << "Got best match for" << referenceLocale.name() << "as" << best_match.name(); + return { referenceLocale.similarity( best_match ), best_match }; + } +} + +/** @brief Returns the QString from @p availableLocales that best-matches. + */ +static LocaleNameParts +identifyBestLanguageMatch( const QString& languageLocale, + const QStringList& availableLocales, + const QString& countryCode ) +{ + const QString default_lang = QStringLiteral( "en_US.UTF-8" ); + + const LocaleNameParts self = LocaleNameParts::fromName( languageLocale ); + if ( self.isValid() && !availableLocales.isEmpty() ) + { + QVector< LocaleNameParts > others; + others.resize( availableLocales.length() ); // Makes default structs + std::transform( availableLocales.begin(), availableLocales.end(), others.begin(), LocaleNameParts::fromName ); + + // Keep track of the best match in various attempts + int best_score = LocaleNameParts::no_match; + LocaleNameParts best_match; + + // Check with the unmodified language setting + { + auto [ score, match ] = identifyBestLanguageMatch( self, others ); + if ( score >= LocaleNameParts::complete_match ) + { + return match; + } + else if ( score > best_score ) + { + best_match = match; + } + } + + + // .. but it might match **better** with the chosen location country Code + { + auto [ score, match ] = identifyBestLanguageMatch( updateCountry( self, countryCode ), others ); + if ( score >= LocaleNameParts::complete_match ) + { + return match; + } + else if ( score > best_score ) + { + best_match = match; + } + } + + // .. or better yet with the QLocale-derived country + { + const QString localeCountry = LocaleNameParts::fromName( QLocale( languageLocale ).name() ).country; + auto [ score, match ] = identifyBestLanguageMatch( updateCountry( self, localeCountry ), others ); + if ( score >= LocaleNameParts::complete_match ) + { + return match; + } + else if ( score > best_score ) + { + best_match = match; + } + } + + if ( best_match.isValid() ) + { + cDebug() << Logger::SubEntry << "Matched best with" << best_match.name(); + return best_match; + } + } + + // Else we have an unrecognized or unsupported locale, all we can do is go with + // en_US.UTF-8 UTF-8. This completes all default language setting guesswork. + return LocaleNameParts::fromName( default_lang ); +} + +LocaleConfiguration +LocaleConfiguration::fromLanguageAndLocation( const QString& languageLocale, + const QStringList& availableLocales, + const QString& countryCode ) +{ + cDebug() << "Mapping" << languageLocale << "in" << countryCode << "to locale."; + const auto bestLocale = identifyBestLanguageMatch( languageLocale, availableLocales, countryCode ); + + // The following block was inspired by Ubiquity, scripts/localechooser-apply. + // No copyright statement found in file, assuming GPL v2 or later. + /* # It is relatively common for the combination of language and location (as + # selected on the timezone page) not to identify a supported combined + # locale. For example, this happens when the user is a migrant, or when + # they prefer to use a different language to interact with their computer + # because that language is better-supported. + # + # In such cases, we would like to be able to use a locale reflecting the + # selected language in LANG for messages, character types, and collation, + # and to make the other locale categories reflect the selected location. + # This means that we have to guess at a suitable locale for the selected + # location, and we do not want to ask yet another locale-related question. + # Nevertheless, some cases are ambiguous: a user who has asked for the + # English language and identifies their location as Switzerland will get + # different numeric representation depending on which Swiss locale we pick. + # + # The goal of identifying a reasonable default for migrants makes things + # easier: it is reasonable to default to French for France despite the + # existence of several minority languages there, because anyone who prefers + # those languages will probably already have selected them and won't arrive + # here. However, in some cases we're unsure, and in some cases we actively + # don't want to pick a "preferred" language: selecting either Greek or + # Turkish as the default language for migrants to Cyprus would probably + # offend somebody! In such cases we simply punt to the old behaviour of not + # setting up a locale reflecting the location, which is suboptimal but is at + # least unlikely to give offence. + # + # Our best shot at general criteria for selecting a default language in + # these circumstances are as follows: + # + # * Exclude special-purpose (e.g. en_DK) and artificial (e.g. la_AU, + # tlh_GB) locales. + # * If there is a language specific to or very strongly associated with the + # country in question, prefer it unless it has rather few native + # speakers. + # * Exclude minority languages that are relatively unlikely to be spoken by + # migrants who have not already selected them as their preferred language + # earlier in the installer. + # * If there is an official national language likely to be seen in print + # media, road signs, etc., then prefer that. + # * In cases of doubt, selecting no default language is safe. */ + + // We make a proposed locale based on the UI language and the timezone's country. There is no + // guarantee that this will be a valid, supported locale (often it won't). + QString lc_formats; + const QString combined = QString( "%1_%2" ).arg( bestLocale.language ).arg( countryCode ); + if ( availableLocales.contains( bestLocale.language ) ) + { + cDebug() << Logger::SubEntry << "Exact formats match for language tag" << bestLocale.language; + lc_formats = bestLocale.language; + } + else if ( availableLocales.contains( combined ) ) + { + cDebug() << Logger::SubEntry << "Exact formats match for combined" << combined; + lc_formats = combined; + } + + if ( lc_formats.isEmpty() ) + { + QStringList available; + for ( const QString& line : availableLocales ) + { + if ( line.contains( QString( "_%1" ).arg( countryCode ) ) ) + { + available.append( line ); + } + } + available.sort(); + if ( available.count() == 1 ) + { + lc_formats = available.first(); + } + else + { + QMap< QString, QString > countryToDefaultLanguage { + { "AU", "en" }, + { "CN", "zh" }, + { "DE", "de" }, + { "DK", "da" }, + { "DZ", "ar" }, + { "ES", "es" }, + // Somewhat unclear: Oromo has the greatest number of + // native speakers; English is the most widely spoken + // language and taught in secondary schools; Amharic is + // the official language and was taught in primary + // schools. + { "ET", "am" }, + { "FI", "fi" }, + { "FR", "fr" }, + { "GB", "en" }, + // Irish (Gaelic) is strongly associated with Ireland, + // but nearly all its native speakers also speak English, + // and migrants are likely to use English. + { "IE", "en" }, + // India has many languages even though Hindi is known as + // national language but English is used in all computer + // and mobile devices. + { "IN", "en" }, + { "IT", "it" }, + { "MA", "ar" }, + { "MK", "mk" }, + { "NG", "en" }, + { "NL", "nl" }, + { "NZ", "en" }, + { "IL", "he" }, + // Filipino is a de facto version of Tagalog, which is + // also spoken; English is also an official language. + { "PH", "fil" }, + { "PK", "ur" }, + { "PL", "pl" }, + { "RU", "ru" }, + // Chinese has more speakers, but English is the "common + // language of the nation" (Wikipedia) and official + // documents must be translated into English to be + // accepted. + { "SG", "en" }, + { "SN", "wo" }, + { "TR", "tr" }, + { "TW", "zh" }, + { "UA", "uk" }, + { "US", "en" }, + { "ZM", "en" } + }; + if ( countryToDefaultLanguage.contains( countryCode ) ) + { + QString combinedLocale + = QString( "%1_%2" ).arg( countryToDefaultLanguage.value( countryCode ) ).arg( countryCode ); + + for ( const QString& line : availableLocales ) + { + if ( line.startsWith( combinedLocale ) ) + { + lc_formats = line; + break; + } + } + } + } + } + + // If we cannot make a good choice for a given country we go with the LANG + // setting, which defaults to en_US.UTF-8 UTF-8 if all else fails. + return LocaleConfiguration( bestLocale.name(), lc_formats.isEmpty() ? bestLocale.name() : lc_formats ); +} + + +bool +LocaleConfiguration::isEmpty() const +{ + return m_lang.isEmpty() && lc_numeric.isEmpty() && lc_time.isEmpty() && lc_monetary.isEmpty() && lc_paper.isEmpty() + && lc_name.isEmpty() && lc_address.isEmpty() && lc_telephone.isEmpty() && lc_measurement.isEmpty() + && lc_identification.isEmpty(); +} + +/// @brief Sets @p value on @p key in the @p map if @p value is non-empty +static inline void +add_lc( QMap< QString, QString >& map, const char* key, const QString& value ) +{ + if ( !value.isEmpty() ) + { + map.insert( key, value ); + } +} + +QMap< QString, QString > +LocaleConfiguration::toMap() const +{ + QMap< QString, QString > map; + + add_lc( map, "LANG", m_lang ); + add_lc( map, "LC_NUMERIC", lc_numeric ); + add_lc( map, "LC_TIME", lc_time ); + add_lc( map, "LC_MONETARY", lc_monetary ); + add_lc( map, "LC_PAPER", lc_paper ); + add_lc( map, "LC_NAME", lc_name ); + add_lc( map, "LC_ADDRESS", lc_address ); + add_lc( map, "LC_TELEPHONE", lc_telephone ); + add_lc( map, "LC_MEASUREMENT", lc_measurement ); + add_lc( map, "LC_IDENTIFICATION", lc_identification ); + + return map; +} diff --git a/calamares/src/modules/locale/LocaleConfiguration.h b/calamares/src/modules/locale/LocaleConfiguration.h new file mode 100644 index 0000000..acd8095 --- /dev/null +++ b/calamares/src/modules/locale/LocaleConfiguration.h @@ -0,0 +1,85 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LOCALECONFIGURATION_H +#define LOCALECONFIGURATION_H + +#include +#include +#include + +class LocaleConfiguration +{ +public: // TODO: private (but need to be public for tests) + /** @brief Create a locale with everything set to the given @p localeName + * + * Consumers should use fromLanguageAndLocation() instead. + */ + explicit LocaleConfiguration( const QString& localeName /* "en_US.UTF-8" */ ) + : LocaleConfiguration( localeName, localeName ) + { + } + /** @brief Create a locale with language and formats separate + * + * Consumers should use fromLanguageAndLocation() instead. + */ + explicit LocaleConfiguration( const QString& localeName, const QString& formatsName ); + + /// @brief Create an empty locale, with nothing set + explicit LocaleConfiguration(); + + /** @brief Create a "sensible" locale configuration for @p language and @p countryCode + * + * This method applies some heuristics to pick a good locale (from the list + * @p availableLocales), along with a good language (for instance, in + * large countries with many languages, picking a generally used one). + */ + static LocaleConfiguration + fromLanguageAndLocation( const QString& language, const QStringList& availableLocales, const QString& countryCode ); + + /// Is this an empty (default-constructed and not modified) configuration? + bool isEmpty() const; + + /** @brief sets language to @p localeName + * + * The language may be regionalized, e.g. "nl_BE". Both the language + * (with region) and BCP47 representation (without region, lowercase) + * are updated. The BCP47 representation is used by the packages module. + * See also `packages.conf` for a discussion of how this is used. + */ + void setLanguage( const QString& localeName ); + /// Current language (including region) + QString language() const { return m_lang; } + /// Current language (lowercase, BCP47 format, no region) + QString toBcp47() const { return m_languageLocaleBcp47; } + + QMap< QString, QString > toMap() const; + + // These become all uppercase in locale.conf, but we keep them lowercase here to + // avoid confusion with , which defines (e.g.) LC_NUMERIC macro. + QString lc_numeric, lc_time, lc_monetary, lc_paper, lc_name, lc_address, lc_telephone, lc_measurement, + lc_identification; + + // If the user has explicitly selected language (from the dialog) + // or numbers format, set these to avoid implicit changes to them. + bool explicit_lang, explicit_lc; + +private: + QString m_lang; + QString m_languageLocaleBcp47; +}; + +inline QDebug& +operator<<( QDebug& s, const LocaleConfiguration& l ) +{ + return s << l.language() << '(' << l.toBcp47() << ") +" << l.lc_numeric; +} + +#endif // LOCALECONFIGURATION_H diff --git a/calamares/src/modules/locale/LocaleNames.cpp b/calamares/src/modules/locale/LocaleNames.cpp new file mode 100644 index 0000000..401aa48 --- /dev/null +++ b/calamares/src/modules/locale/LocaleNames.cpp @@ -0,0 +1,90 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LocaleNames.h" + +#include "utils/Logger.h" + +#include + +LocaleNameParts +LocaleNameParts::fromName( const QString& name ) +{ + auto requireAndRemoveLeadingChar = []( QChar c, QString s ) + { + if ( s.startsWith( c ) ) + { + return s.remove( 0, 1 ); + } + else + { + return QString(); + } + }; + + auto parts = QRegularExpression( "^([a-zA-Z]+)(_[a-zA-Z]+)?(\\.[-a-zA-Z0-9]+)?(@[a-zA-Z]+)?" ).match( name ); + const QString calamaresLanguage = parts.captured( 1 ); + const QString calamaresCountry = requireAndRemoveLeadingChar( '_', parts.captured( 2 ) ); + const QString calamaresEncoding = requireAndRemoveLeadingChar( '.', parts.captured( 3 ) ); + const QString calamaresRegion = requireAndRemoveLeadingChar( '@', parts.captured( 4 ) ); + + if ( calamaresLanguage.isEmpty() ) + { + return LocaleNameParts {}; + } + else + { + return LocaleNameParts { calamaresLanguage, calamaresCountry, calamaresRegion, calamaresEncoding }; + } +} + +QString +LocaleNameParts::name() const +{ + // We don't want QStringView to a temporary; force conversion + auto insertLeadingChar = []( QChar c, QString s ) -> QString + { + if ( s.isEmpty() ) + { + return QString(); + } + else + { + return c + s; + } + }; + + if ( !isValid() ) + { + return QString(); + } + else + { + return language + insertLeadingChar( '_', country ) + insertLeadingChar( '.', encoding ) + + insertLeadingChar( '@', region ); + } +} + + +int +LocaleNameParts::similarity( const LocaleNameParts& other ) const +{ + if ( !isValid() || !other.isValid() ) + { + return 0; + } + if ( language != other.language ) + { + return 0; + } + const auto matched_region = ( region == other.region ? 30 : 0 ); + const auto matched_country = ( country == other.country ? ( country.isEmpty() ? 10 : 20 ) : 0 ); + const auto no_other_country_given = ( ( country != other.country && other.country.isEmpty() ) ? 10 : 0 ); + return 50 + matched_region + matched_country + no_other_country_given; +} diff --git a/calamares/src/modules/locale/LocaleNames.h b/calamares/src/modules/locale/LocaleNames.h new file mode 100644 index 0000000..8498aa2 --- /dev/null +++ b/calamares/src/modules/locale/LocaleNames.h @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LOCALENAMES_H +#define LOCALENAMES_H + +#include + +/** @brief parts of a locale-name (e.g. "ar_LY.UTF-8", split apart) + * + * These are created from lines in `/usr/share/i18n/SUPPORTED`, + * which lists all the locales supported by the system (there + * are also other sources of the same). + * + */ +struct LocaleNameParts +{ + QString language; // e.g. "ar" + QString country; // e.g. "LY" (may be empty) + QString region; // e.g. "@valencia" (may be empty) + QString encoding; // e.g. "UTF-8" (may be empty) + + bool isValid() const { return !language.isEmpty(); } + QString name() const; + + static LocaleNameParts fromName( const QString& name ); + + static inline constexpr const int no_match = 0; + static inline constexpr const int complete_match = 100; + + /** @brief Compute similarity-score with another locale-name. + * + * Similarity is driven by language and region, then country. + * Returns a number between 0 (no similarity, e.g. the + * language is different) and 100 (complete match). + */ + int similarity( const LocaleNameParts& other ) const; +}; + +#endif diff --git a/calamares/src/modules/locale/LocalePage.cpp b/calamares/src/modules/locale/LocalePage.cpp new file mode 100644 index 0000000..e74373a --- /dev/null +++ b/calamares/src/modules/locale/LocalePage.cpp @@ -0,0 +1,227 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LocalePage.h" + +#include "Config.h" +#include "LCLocaleDialog.h" +#include "timezonewidget/timezonewidget.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/Retranslator.h" + +#include +#include +#include +#include +#include + +LocalePage::LocalePage( Config* config, QWidget* parent ) + : QWidget( parent ) + , m_config( config ) + , m_blockTzWidgetSet( false ) +{ + QBoxLayout* mainLayout = new QVBoxLayout; + + QBoxLayout* tzwLayout = new QHBoxLayout; + m_tzWidget = new TimeZoneWidget( m_config->zonesModel(), this ); + tzwLayout->addStretch(); + tzwLayout->addWidget( m_tzWidget ); + tzwLayout->addStretch(); + // Adjust for margins and spacing in this page + m_tzWidget->setMinimumHeight( m_tzWidget->minimumHeight() + 12 ); // 2 * spacing + + QBoxLayout* zoneAndRegionLayout = new QHBoxLayout; + m_regionLabel = new QLabel( this ); + zoneAndRegionLayout->addWidget( m_regionLabel ); + + m_regionCombo = new QComboBox( this ); + zoneAndRegionLayout->addWidget( m_regionCombo ); + m_regionCombo->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred ); + m_regionLabel->setBuddy( m_regionCombo ); + + zoneAndRegionLayout->addSpacing( 20 ); + + m_zoneLabel = new QLabel( this ); + zoneAndRegionLayout->addWidget( m_zoneLabel ); + + m_zoneCombo = new QComboBox( this ); + m_zoneCombo->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred ); + zoneAndRegionLayout->addWidget( m_zoneCombo ); + m_zoneLabel->setBuddy( m_zoneCombo ); + + + QBoxLayout* localeLayout = new QHBoxLayout; + m_localeLabel = new QLabel( this ); + m_localeLabel->setWordWrap( true ); + m_localeLabel->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred ); + localeLayout->addWidget( m_localeLabel ); + + m_localeChangeButton = new QPushButton( this ); + m_localeChangeButton->setSizePolicy( QSizePolicy::Preferred, QSizePolicy::Preferred ); + localeLayout->addWidget( m_localeChangeButton ); + + QBoxLayout* formatsLayout = new QHBoxLayout; + m_formatsLabel = new QLabel( this ); + m_formatsLabel->setWordWrap( true ); + m_formatsLabel->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred ); + formatsLayout->addWidget( m_formatsLabel ); + + m_formatsChangeButton = new QPushButton( this ); + m_formatsChangeButton->setSizePolicy( QSizePolicy::Preferred, QSizePolicy::Preferred ); + formatsLayout->addWidget( m_formatsChangeButton ); + + mainLayout->addLayout( tzwLayout ); + mainLayout->addStretch(); + mainLayout->addLayout( zoneAndRegionLayout ); + mainLayout->addStretch(); + mainLayout->addLayout( localeLayout ); + mainLayout->addLayout( formatsLayout ); + setMinimumWidth( m_tzWidget->width() ); + setLayout( mainLayout ); + + // Set up the location before connecting signals, to avoid a signal + // storm as various parts interact. + { + auto* regions = m_config->regionModel(); + auto* zones = m_config->regionalZonesModel(); + auto* location = m_config->currentLocation(); + zones->setRegion( location->region() ); + m_regionCombo->setModel( regions ); + m_zoneCombo->setModel( zones ); + m_tzWidget->setCurrentLocation( location ); + locationChanged( location ); // doesn't inform TZ widget + } + + connect( config, &Config::currentLCStatusChanged, m_formatsLabel, &QLabel::setText ); + connect( config, &Config::currentLanguageStatusChanged, m_localeLabel, &QLabel::setText ); + connect( config, &Config::currentLocationChanged, m_tzWidget, &TimeZoneWidget::setCurrentLocation ); + connect( config, &Config::currentLocationChanged, this, &LocalePage::locationChanged ); + connect( m_tzWidget, + &TimeZoneWidget::locationChanged, + config, + QOverload< const Calamares::Locale::TimeZoneData* >::of( &Config::setCurrentLocation ) ); + + connect( m_regionCombo, QOverload< int >::of( &QComboBox::currentIndexChanged ), this, &LocalePage::regionChanged ); + connect( m_zoneCombo, QOverload< int >::of( &QComboBox::currentIndexChanged ), this, &LocalePage::zoneChanged ); + + connect( m_localeChangeButton, &QPushButton::clicked, this, &LocalePage::changeLocale ); + connect( m_formatsChangeButton, &QPushButton::clicked, this, &LocalePage::changeFormats ); + + CALAMARES_RETRANSLATE_SLOT( &LocalePage::updateLocaleLabels ); +} + + +LocalePage::~LocalePage() {} + + +void +LocalePage::updateLocaleLabels() +{ + m_regionLabel->setText( tr( "Region:", "@label" ) ); + m_zoneLabel->setText( tr( "Zone:", "@label" ) ); + m_localeChangeButton->setText( tr( "&Change…", "@button" ) ); + m_formatsChangeButton->setText( tr( "&Change…", "@button" ) ); + m_localeLabel->setText( m_config->currentLanguageStatus() ); + m_formatsLabel->setText( m_config->currentLCStatus() ); +} + + +void +LocalePage::onActivate() +{ + m_regionCombo->setFocus(); + updateLocaleLabels(); +} + + +void +LocalePage::regionChanged( int currentIndex ) +{ + using namespace Calamares::Locale; + + QString selectedRegion = m_regionCombo->itemData( currentIndex ).toString(); + { + cSignalBlocker z( m_zoneCombo ); + m_config->regionalZonesModel()->setRegion( selectedRegion ); + } + m_zoneCombo->currentIndexChanged( 0 ); +} + +void +LocalePage::zoneChanged( int currentIndex ) +{ + if ( !m_blockTzWidgetSet ) + { + m_config->setCurrentLocation( m_regionCombo->currentData().toString(), + m_zoneCombo->itemData( currentIndex ).toString() ); + } +} + +void +LocalePage::locationChanged( const Calamares::Locale::TimeZoneData* location ) +{ + if ( !location ) + { + return; + } + cScopedAssignment b( &m_blockTzWidgetSet, true, false ); + + // Set region index + int index = m_regionCombo->findData( location->region() ); + if ( index < 0 ) + { + return; + } + + m_regionCombo->setCurrentIndex( index ); + + // Set zone index + index = m_zoneCombo->findData( location->zone() ); + if ( index < 0 ) + { + return; + } + + m_zoneCombo->setCurrentIndex( index ); +} + +void +LocalePage::changeLocale() +{ + QPointer< LCLocaleDialog > dlg( + new LCLocaleDialog( m_config->localeConfiguration().language(), m_config->supportedLocales(), this ) ); + dlg->exec(); + if ( dlg && dlg->result() == QDialog::Accepted && !dlg->selectedLCLocale().isEmpty() ) + { + m_config->setLanguageExplicitly( dlg->selectedLCLocale() ); + updateLocaleLabels(); + } + + delete dlg; +} + + +void +LocalePage::changeFormats() +{ + QPointer< LCLocaleDialog > dlg( + new LCLocaleDialog( m_config->localeConfiguration().lc_numeric, m_config->supportedLocales(), this ) ); + dlg->exec(); + if ( dlg && dlg->result() == QDialog::Accepted && !dlg->selectedLCLocale().isEmpty() ) + { + m_config->setLCLocaleExplicitly( dlg->selectedLCLocale() ); + updateLocaleLabels(); + } + + delete dlg; +} diff --git a/calamares/src/modules/locale/LocalePage.h b/calamares/src/modules/locale/LocalePage.h new file mode 100644 index 0000000..66502e6 --- /dev/null +++ b/calamares/src/modules/locale/LocalePage.h @@ -0,0 +1,66 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LOCALEPAGE_H +#define LOCALEPAGE_H + +#include "LocaleConfiguration.h" + +#include "Job.h" +#include "locale/TimeZone.h" + +#include + +#include + +class QComboBox; +class QLabel; +class QPushButton; + +class Config; +class TimeZoneWidget; + +class LocalePage : public QWidget +{ + Q_OBJECT +public: + explicit LocalePage( class Config* config, QWidget* parent = nullptr ); + ~LocalePage() override; + + void onActivate(); + +private: + /// @brief Non-owning pointer to the ViewStep's config + Config* m_config; + + void updateLocaleLabels(); + + void regionChanged( int currentIndex ); + void zoneChanged( int currentIndex ); + void locationChanged( const Calamares::Locale::TimeZoneData* location ); + void changeLocale(); + void changeFormats(); + + TimeZoneWidget* m_tzWidget; + QComboBox* m_regionCombo; + QComboBox* m_zoneCombo; + + QLabel* m_regionLabel; + QLabel* m_zoneLabel; + QLabel* m_localeLabel; + QPushButton* m_localeChangeButton; + QLabel* m_formatsLabel; + QPushButton* m_formatsChangeButton; + + + bool m_blockTzWidgetSet; +}; + +#endif // LOCALEPAGE_H diff --git a/calamares/src/modules/locale/LocaleViewStep.cpp b/calamares/src/modules/locale/LocaleViewStep.cpp new file mode 100644 index 0000000..87290a8 --- /dev/null +++ b/calamares/src/modules/locale/LocaleViewStep.cpp @@ -0,0 +1,143 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LocaleViewStep.h" + +#include "LocalePage.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "geoip/Handler.h" +#include "network/Manager.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Variant.h" +#include "utils/Yaml.h" + +#include +#include + +CALAMARES_PLUGIN_FACTORY_DEFINITION( LocaleViewStepFactory, registerPlugin< LocaleViewStep >(); ) + +LocaleViewStep::LocaleViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_widget( new QWidget() ) + , m_actualWidget( nullptr ) + , m_nextEnabled( false ) + , m_config( std::make_unique< Config >() ) +{ + QBoxLayout* mainLayout = new QHBoxLayout; + m_widget->setLayout( mainLayout ); + Calamares::unmarginLayout( mainLayout ); + + emit nextStatusChanged( m_nextEnabled ); +} + +LocaleViewStep::~LocaleViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + +void +LocaleViewStep::setUpPage() +{ + m_config->setCurrentLocation(); + if ( !m_actualWidget ) + { + m_actualWidget = new LocalePage( m_config.get() ); + } + m_widget->layout()->addWidget( m_actualWidget ); + + ensureSize( m_actualWidget->sizeHint() ); + + m_nextEnabled = true; + emit nextStatusChanged( m_nextEnabled ); +} + +QString +LocaleViewStep::prettyName() const +{ + return tr( "Location", "@label" ); +} + +QString +LocaleViewStep::prettyStatus() const +{ + return m_config->prettyStatus(); +} + +QWidget* +LocaleViewStep::widget() +{ + return m_widget; +} + +bool +LocaleViewStep::isNextEnabled() const +{ + return m_nextEnabled; +} + +bool +LocaleViewStep::isBackEnabled() const +{ + return true; +} + +bool +LocaleViewStep::isAtBeginning() const +{ + return true; +} + +bool +LocaleViewStep::isAtEnd() const +{ + return true; +} + +Calamares::JobList +LocaleViewStep::jobs() const +{ + return m_config->createJobs(); +} + +void +LocaleViewStep::onActivate() +{ + m_config->setCurrentLocation(); // Finalize the location + if ( !m_actualWidget ) + { + setUpPage(); + } + m_actualWidget->onActivate(); +} + +void +LocaleViewStep::onLeave() +{ + m_config->finalizeGlobalStorage(); +} + +void +LocaleViewStep::onCancel() +{ + m_config->cancel(); +} + +void +LocaleViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); +} diff --git a/calamares/src/modules/locale/LocaleViewStep.h b/calamares/src/modules/locale/LocaleViewStep.h new file mode 100644 index 0000000..6244219 --- /dev/null +++ b/calamares/src/modules/locale/LocaleViewStep.h @@ -0,0 +1,65 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LOCALEVIEWSTEP_H +#define LOCALEVIEWSTEP_H + +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include + +class LocalePage; + +class PLUGINDLLEXPORT LocaleViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit LocaleViewStep( QObject* parent = nullptr ); + ~LocaleViewStep() override; + + QString prettyName() const override; + QString prettyStatus() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onActivate() override; + void onLeave() override; + void onCancel() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private slots: + void setUpPage(); + +private: + QWidget* m_widget; + + LocalePage* m_actualWidget; + bool m_nextEnabled; + + std::unique_ptr< Config > m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( LocaleViewStepFactory ) + +#endif // LOCALEVIEWSTEP_H diff --git a/calamares/src/modules/locale/SetTimezoneJob.cpp b/calamares/src/modules/locale/SetTimezoneJob.cpp new file mode 100644 index 0000000..3d88bd0 --- /dev/null +++ b/calamares/src/modules/locale/SetTimezoneJob.cpp @@ -0,0 +1,89 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2015 Rohan Garg + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SetTimezoneJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include +#include + +SetTimezoneJob::SetTimezoneJob( const QString& region, const QString& zone ) + : Calamares::Job() + , m_region( region ) + , m_zone( zone ) +{ +} + +QString +SetTimezoneJob::prettyName() const +{ + return tr( "Setting timezone to %1/%2…", "@status" ).arg( m_region ).arg( m_zone ); +} + +Calamares::JobResult +SetTimezoneJob::exec() +{ + // do not call timedatectl in a chroot, it is not safe (timedatectl talks + // to a running timedated over D-Bus), and we have code that works + if ( !Calamares::Settings::instance()->doChroot() ) + { + int ec = Calamares::System::instance()->targetEnvCall( + { "timedatectl", "set-timezone", m_region + '/' + m_zone } ); + + if ( !ec ) + { + return Calamares::JobResult::ok(); + } + } + + QString localtimeSlink( "/etc/localtime" ); + QString zoneinfoPath( "/usr/share/zoneinfo" ); + zoneinfoPath.append( QDir::separator() + m_region ); + zoneinfoPath.append( QDir::separator() + m_zone ); + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + QFileInfo zoneFile( gs->value( "rootMountPoint" ).toString() + zoneinfoPath ); + if ( !zoneFile.exists() || !zoneFile.isReadable() ) + { + return Calamares::JobResult::error( tr( "Cannot access selected timezone path.", "@error" ), + tr( "Bad path: %1", "@error" ).arg( zoneFile.absolutePath() ) ); + } + + // Make sure /etc/localtime doesn't exist, otherwise symlinking will fail + Calamares::System::instance()->targetEnvCall( { "rm", "-f", localtimeSlink } ); + + int ec = Calamares::System::instance()->targetEnvCall( { "ln", "-s", zoneinfoPath, localtimeSlink } ); + if ( ec ) + { + return Calamares::JobResult::error( tr( "Cannot set timezone.", "@error" ), + tr( "Link creation failed, target: %1; link name: %2", "@info" ) + .arg( zoneinfoPath ) + .arg( "/etc/localtime" ) ); + } + + QFile timezoneFile( gs->value( "rootMountPoint" ).toString() + "/etc/timezone" ); + + if ( !timezoneFile.open( QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate ) ) + { + return Calamares::JobResult::error( tr( "Cannot set timezone.", "@error" ), + tr( "Cannot open /etc/timezone for writing", "@info" ) ); + } + + QTextStream out( &timezoneFile ); + out << m_region << '/' << m_zone << "\n"; + timezoneFile.close(); + + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/locale/SetTimezoneJob.h b/calamares/src/modules/locale/SetTimezoneJob.h new file mode 100644 index 0000000..d511712 --- /dev/null +++ b/calamares/src/modules/locale/SetTimezoneJob.h @@ -0,0 +1,30 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SETTIMEZONEJOB_H +#define SETTIMEZONEJOB_H + +#include "Job.h" + + +class SetTimezoneJob : public Calamares::Job +{ + Q_OBJECT +public: + SetTimezoneJob( const QString& region, const QString& zone ); + + QString prettyName() const override; + Calamares::JobResult exec() override; + +private: + QString m_region; + QString m_zone; +}; + +#endif /* SETTIMEZONEJOB_H */ diff --git a/calamares/src/modules/locale/Tests.cpp b/calamares/src/modules/locale/Tests.cpp new file mode 100644 index 0000000..a321b2f --- /dev/null +++ b/calamares/src/modules/locale/Tests.cpp @@ -0,0 +1,587 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" +#include "LocaleConfiguration.h" +#include "LocaleNames.h" +#include "timezonewidget/TimeZoneImage.h" + +#include "Settings.h" +#include "locale/TimeZone.h" +#include "locale/TranslationsModel.h" +#include "utils/Logger.h" + +#include + +#include + +class LocaleTests : public QObject +{ + Q_OBJECT +public: + LocaleTests(); + ~LocaleTests() override; + + // Implementation of data for MappingNeon and MappingFreeBSD + void MappingData(); + +private Q_SLOTS: + void initTestCase(); + // Check the sample config file is processed correctly + void testEmptyLocaleConfiguration(); + void testDefaultLocaleConfiguration(); + void testSplitLocaleConfiguration(); + + // Check the TZ images for consistency + void testTZSanity(); + void testTZImages(); // No overlaps in images + void testTZLocations(); // No overlaps in locations + void testSpecificLocations(); + + // Check the Config loading + void testConfigInitialization(); + void testLanguageDetection_data(); + void testLanguageDetection(); + void testLanguageDetectionValencia(); + + // Check that the test-data is available and ok + void testKDENeonLanguageData(); + void testLocaleNameParts(); + + // Check realistic language mapping for issue 2008 + void testLanguageMappingNeon_data(); + void testLanguageMappingNeon(); + void testLanguageMappingFreeBSD_data(); + void testLanguageMappingFreeBSD(); + void testLanguageSimilarity(); + +private: + QStringList m_KDEneonLocales; + QStringList m_FreeBSDLocales; +}; + +QTEST_GUILESS_MAIN( LocaleTests ) + + +LocaleTests::LocaleTests() {} + +LocaleTests::~LocaleTests() {} + +void +LocaleTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + const auto* settings = Calamares::Settings::instance(); + if ( !settings ) + { + (void)new Calamares::Settings( true ); + } +} + +void +LocaleTests::testEmptyLocaleConfiguration() +{ + LocaleConfiguration lc; + + QVERIFY( lc.isEmpty() ); + QCOMPARE( lc.toBcp47(), QString() ); +} + +void +LocaleTests::testDefaultLocaleConfiguration() +{ + LocaleConfiguration lc( "en_US.UTF-8" ); + QVERIFY( !lc.isEmpty() ); + QCOMPARE( lc.language(), QStringLiteral( "en_US.UTF-8" ) ); + QCOMPARE( lc.toBcp47(), QStringLiteral( "en" ) ); + + LocaleConfiguration lc2( "de_DE.UTF-8" ); + QVERIFY( !lc2.isEmpty() ); + QCOMPARE( lc2.language(), QStringLiteral( "de_DE.UTF-8" ) ); + QCOMPARE( lc2.toBcp47(), QStringLiteral( "de" ) ); +} + +void +LocaleTests::testSplitLocaleConfiguration() +{ + LocaleConfiguration lc( "en_US.UTF-8", "de_DE.UTF-8" ); + QVERIFY( !lc.isEmpty() ); + QCOMPARE( lc.language(), QStringLiteral( "en_US.UTF-8" ) ); + QCOMPARE( lc.toBcp47(), QStringLiteral( "en" ) ); + QCOMPARE( lc.lc_numeric, QStringLiteral( "de_DE.UTF-8" ) ); + + LocaleConfiguration lc2( "de_DE.UTF-8", "da_DK.UTF-8" ); + QVERIFY( !lc2.isEmpty() ); + QCOMPARE( lc2.language(), QStringLiteral( "de_DE.UTF-8" ) ); + QCOMPARE( lc2.toBcp47(), QStringLiteral( "de" ) ); + QCOMPARE( lc2.lc_numeric, QStringLiteral( "da_DK.UTF-8" ) ); + + LocaleConfiguration lc3( "da_DK.UTF-8", "de_DE.UTF-8" ); + QVERIFY( !lc3.isEmpty() ); + QCOMPARE( lc3.toBcp47(), QStringLiteral( "da" ) ); + QCOMPARE( lc3.lc_numeric, QStringLiteral( "de_DE.UTF-8" ) ); +} + +void +LocaleTests::testTZSanity() +{ + // Data source for all TZ info + QVERIFY( QFile( "/usr/share/zoneinfo/zone.tab" ).exists() ); + + // Contains a sensible number of total zones + const Calamares::Locale::ZonesModel zones; + QVERIFY( zones.rowCount( QModelIndex() ) > 100 ); +} + + +void +LocaleTests::testTZImages() +{ + // This test messes around with log-levels a lot so + // that it produces useful output (e.g. listing the problems, + // not every check it ever does). + Logger::setupLogLevel( Logger::LOGDEBUG ); + + // Number of zone images + // + // + auto images = TimeZoneImageList::fromDirectory( SOURCE_DIR ); + QCOMPARE( images.count(), images.zoneCount ); + + // All image sizes consistent + // + // + const QSize windowSize( 780, 340 ); + { + QImage background( SOURCE_DIR "/bg.png" ); + QVERIFY( !background.isNull() ); + QCOMPARE( background.size(), windowSize ); + } + for ( const auto& image : images ) + { + QCOMPARE( image.size(), windowSize ); + } + + // Check zones are uniquely-claimed + // + // + using namespace Calamares::Locale; + const ZonesModel m; + + int overlapcount = 0; + for ( auto it = m.begin(); it; ++it ) + { + QString region = m.data( m.index( it.index() ), ZonesModel::RegionRole ).toString(); + QString zoneName = m.data( m.index( it.index() ), ZonesModel::KeyRole ).toString(); + QVERIFY( !region.isEmpty() ); + QVERIFY( !zoneName.isEmpty() ); + const auto* zone = m.find( region, zoneName ); + const auto* iterzone = *it; + + QVERIFY( iterzone ); + QVERIFY( zone ); + QCOMPARE( zone, iterzone ); + QCOMPARE( zone->zone(), zoneName ); + QCOMPARE( zone->region(), region ); + + int overlap = 0; + auto pos = images.getLocationPosition( zone->longitude(), zone->latitude() ); + QVERIFY( images.index( pos, overlap ) >= 0 ); + QVERIFY( overlap > 0 ); // At least one image contains the spot + if ( overlap > 1 ) + { + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << Logger::SubEntry << "Zone" << zone->zone() << pos; + (void)images.index( pos, overlap ); + Logger::setupLogLevel( Logger::LOGERROR ); + overlapcount++; + } + } + + QEXPECT_FAIL( "", "TZ Images not yet all fixed", Continue ); + QCOMPARE( overlapcount, 0 ); +} + +bool +operator<( const QPoint& l, const QPoint& r ) +{ + if ( l.x() < r.x() ) + { + return true; + } + if ( l.x() > r.x() ) + { + return false; + } + return l.y() < r.y(); +} + +void +listAll( const QPoint& p, const Calamares::Locale::ZonesModel& zones ) +{ + using namespace Calamares::Locale; + for ( auto it = zones.begin(); it; ++it ) + { + const auto* zone = *it; + if ( !zone ) + { + cError() << Logger::SubEntry << "NULL zone"; + return; + } + if ( p == TimeZoneImageList::getLocationPosition( zone->longitude(), zone->latitude() ) ) + { + cError() << Logger::SubEntry << zone->zone(); + } + } +} + +void +LocaleTests::testTZLocations() +{ + using namespace Calamares::Locale; + ZonesModel zones; + + QVERIFY( zones.rowCount( QModelIndex() ) > 100 ); + + int overlapcount = 0; + std::set< QPoint > occupied; + for ( auto it = zones.begin(); it; ++it ) + { + const auto* zone = *it; + QVERIFY( zone ); + + auto pos = TimeZoneImageList::getLocationPosition( zone->longitude(), zone->latitude() ); + if ( occupied.find( pos ) != occupied.end() ) + { + cError() << "Zone" << zone->zone() << "occupies same spot as .."; + listAll( pos, zones ); + overlapcount++; + } + occupied.insert( pos ); + } + + QEXPECT_FAIL( "", "TZ Images contain pixel-overlaps", Continue ); + QCOMPARE( overlapcount, 0 ); +} + +void +LocaleTests::testSpecificLocations() +{ + Calamares::Locale::ZonesModel zones; + const auto* gibraltar = zones.find( "Europe", "Gibraltar" ); + const auto* ceuta = zones.find( "Africa", "Ceuta" ); + QVERIFY( gibraltar ); + QVERIFY( ceuta ); + + auto gpos = TimeZoneImageList::getLocationPosition( gibraltar->longitude(), gibraltar->latitude() ); + auto cpos = TimeZoneImageList::getLocationPosition( ceuta->longitude(), ceuta->latitude() ); + QEXPECT_FAIL( "", "Gibraltar and Ceuta are really close", Continue ); + QVERIFY( gpos != cpos ); + QVERIFY( gibraltar->latitude() > ceuta->latitude() ); + QEXPECT_FAIL( "", "Gibraltar and Ceuta are really close", Continue ); + QVERIFY( gpos.y() < cpos.y() ); // Gibraltar is north of Ceuta +} + +void +LocaleTests::testConfigInitialization() +{ + Config c; + + QCOMPARE( c.currentLocation(), nullptr ); + QVERIFY( c.currentLocationStatus().isEmpty() ); +} + +void +LocaleTests::testLanguageDetection_data() +{ + QTest::addColumn< QString >( "locale" ); + QTest::addColumn< QString >( "country" ); + QTest::addColumn< QString >( "expected" ); + + QTest::newRow( "english (US)" ) << QStringLiteral( "en" ) << QStringLiteral( "US" ) + << QStringLiteral( "en_US.UTF-8" ); + QTest::newRow( "english (CA)" ) << QStringLiteral( "en" ) << QStringLiteral( "CA" ) + << QStringLiteral( "en_US.UTF-8" ); + QTest::newRow( "english (GB)" ) << QStringLiteral( "en" ) << QStringLiteral( "GB" ) + << QStringLiteral( "en_GB.UTF-8" ); + QTest::newRow( "english (NL)" ) << QStringLiteral( "en" ) << QStringLiteral( "NL" ) + << QStringLiteral( "en_US.UTF-8" ); + + QTest::newRow( "portuguese (PT)" ) << QStringLiteral( "pt" ) << QStringLiteral( "PT" ) + << QStringLiteral( "pt_PT.UTF-8" ); + QTest::newRow( "portuguese (NL)" ) << QStringLiteral( "pt" ) << QStringLiteral( "NL" ) + << QStringLiteral( "pt_BR.UTF-8" ); // first + QTest::newRow( "portuguese (BR)" ) << QStringLiteral( "pt" ) << QStringLiteral( "BR" ) + << QStringLiteral( "pt_BR.UTF-8" ); + + QTest::newRow( "catalan ()" ) << QStringLiteral( "ca" ) << QStringLiteral( "" ) + << QStringLiteral( "ca_ES.UTF-8" ); // no country given? Matches QLocale-default + QTest::newRow( "catalan (ES)" ) << QStringLiteral( "ca" ) << QStringLiteral( "ES" ) + << QStringLiteral( "ca_ES.UTF-8" ); + QTest::newRow( "catalan (NL)" ) << QStringLiteral( "ca" ) << QStringLiteral( "NL" ) + << QStringLiteral( "ca_ES.UTF-8" ); + QTest::newRow( "catalan (@valencia)" ) << QStringLiteral( "ca@valencia" ) << QStringLiteral( "ES" ) + << QStringLiteral( "ca_ES@valencia" ); // Prefers regional variant + QTest::newRow( "catalan (@valencia_NL)" ) + << QStringLiteral( "ca@valencia" ) << QStringLiteral( "NL" ) << QStringLiteral( "ca_ES@valencia" ); +} + + +/* + * This list of available locales was created by grepping `/etc/locale.gen` + * on an EndeavourOS ISO image for a handful of representative locales. + */ +static const QStringList availableLocales { + "nl_AW", "nl_BE.UTF-8", "nl_NL.UTF-8", "en", "en_AU.UTF-8", "en_US.UTF-8", "en_GB.UTF-8", + "ca_AD.UTF-8", "ca_ES.UTF-8", "ca_ES@valencia", "sr_ME", "sr_RS", "sr_RS@latin", "pt_BR.UTF-8", + "pt_PT.UTF-8", "es_AR.UTF-8", "es_ES.UTF-8", "es_MX.UTF-8", +}; + +void +LocaleTests::testLanguageDetection() +{ + QFETCH( QString, locale ); + QFETCH( QString, country ); + QFETCH( QString, expected ); + + auto r = LocaleConfiguration::fromLanguageAndLocation( locale, availableLocales, country ); + QCOMPARE( r.language(), expected ); +} + +void +LocaleTests::testLanguageDetectionValencia() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + { + auto r = LocaleConfiguration::fromLanguageAndLocation( + QStringLiteral( "nl" ), availableLocales, QStringLiteral( "NL" ) ); + QCOMPARE( r.language(), "nl_NL.UTF-8" ); + } + { + auto r = LocaleConfiguration::fromLanguageAndLocation( + QStringLiteral( "ca@valencia" ), availableLocales, QStringLiteral( "NL" ) ); + QCOMPARE( r.language(), "ca_ES@valencia" ); + } + { + auto r = LocaleConfiguration::fromLanguageAndLocation( + QStringLiteral( "sr" ), availableLocales, QStringLiteral( "NL" ) ); + QCOMPARE( r.language(), "sr_RS" ); // Because that one is first in the list + } + { + auto r = LocaleConfiguration::fromLanguageAndLocation( + QStringLiteral( "sr@latin" ), availableLocales, QStringLiteral( "NL" ) ); + QCOMPARE( r.language(), "sr_RS@latin" ); + } +} + +static QStringList +splitTestFileIntoLines( const QString& filename ) +{ + // BUILD_AS_TEST is the source-directory path + const QFileInfo fi( QString( "%1/tests/%2" ).arg( BUILD_AS_TEST, filename ) ); + const QString path = fi.absoluteFilePath(); + QFile testData( path ); + if ( testData.open( QIODevice::ReadOnly ) ) + { + return QString::fromUtf8( testData.readAll() ).split( '\n', Qt::SkipEmptyParts ); + } + return QStringList {}; +} + +void +LocaleTests::testKDENeonLanguageData() +{ + if ( !m_KDEneonLocales.isEmpty() ) + { + return; + } + const QStringList neonLocales = splitTestFileIntoLines( QStringLiteral( "locale-data-neon" ) ); + cDebug() << "Loaded KDE neon locales test data" << neonLocales.front() << "to" << neonLocales.back(); + QCOMPARE( neonLocales.length(), 318 ); // wc -l tells me 318 lines + m_KDEneonLocales = neonLocales; + + const QStringList bsdLocales = splitTestFileIntoLines( QStringLiteral( "locale-data-freebsd" ) ); + cDebug() << "Loaded FreeBSD locales test data" << bsdLocales.front() << "to" << bsdLocales.back(); + QCOMPARE( bsdLocales.length(), 79 ); + m_FreeBSDLocales = bsdLocales; +} + +void +LocaleTests::MappingData() +{ + QTest::addColumn< QString >( "selectedLanguage" ); + QTest::addColumn< QString >( "KDEneonLanguage" ); + QTest::addColumn< QString >( "FreeBSDLanguage" ); + + // Tired of writing QString or QStringLiteral all the time. + auto l = []( const char* p ) { return QString::fromUtf8( p ); }; + auto u = []() { return QString(); }; + + // The KDEneon columns include the .UTF-8 from the source data + // The FreeBSD columns may have u() to indicate "same as KDEneon", + // that's an empty string. + // + // Each row shows how a language -- which can be selected from the + // welcome page, and is inserted into GS as the language key that + // Calamares knows -- should be mapped to a supported system locale. + // + // All the mappings are for ".. in NL", which can trigger minor variation + // if there are languages with a _NL variant (e.g. nl_NL and nl_BE). + + // clang-format off + QTest::newRow( "en " ) << l( "en" ) << l( "en_US.UTF-8" ) << u(); + QTest::newRow( "en_GB" ) << l( "en_GB" ) << l( "en_GB.UTF-8" ) << u(); + QTest::newRow( "ca " ) << l( "ca" ) << l( "ca_ES.UTF-8" ) << u(); + // FreeBSD has no Valencian variant + QTest::newRow( "ca@vl" ) << l( "ca@valencia" ) << l( "ca_ES@valencia" ) << l( "ca_ES.UTF-8" ); + // FreeBSD has the UTF-8 marker before the @region part + QTest::newRow( "sr " ) << l( "sr" ) << l( "sr_RS" ) << l( "sr_RS.UTF-8" ); + QTest::newRow( "sr@lt" ) << l( "sr@latin" ) << l( "sr_RS@latin" ) << l( "sr_RS.UTF-8@latin" ); + QTest::newRow( "pt_PT" ) << l( "pt_PT" ) << l( "pt_PT.UTF-8" ) << u(); + QTest::newRow( "pt_BR" ) << l( "pt_BR" ) << l( "pt_BR.UTF-8" ) << u(); + QTest::newRow( "nl " ) << l( "nl" ) << l( "nl_NL.UTF-8" ) << u(); + QTest::newRow( "zh_TW" ) << l( "zh_TW" ) << l( "zh_TW.UTF-8" ) << u(); + // clang-format on +} + + +void +LocaleTests::testLanguageMappingNeon_data() +{ + MappingData(); +} + +void +LocaleTests::testLanguageMappingFreeBSD_data() +{ + MappingData(); +} + +void +LocaleTests::testLanguageMappingNeon() +{ + testKDENeonLanguageData(); + QVERIFY( !m_KDEneonLocales.isEmpty() ); + + QFETCH( QString, selectedLanguage ); + QFETCH( QString, KDEneonLanguage ); + QFETCH( QString, FreeBSDLanguage ); + + QVERIFY( Calamares::Locale::availableLanguages().contains( selectedLanguage ) ); + + const auto neon = LocaleConfiguration::fromLanguageAndLocation( + ( selectedLanguage ), m_KDEneonLocales, QStringLiteral( "NL" ) ); + QCOMPARE( neon.language(), KDEneonLanguage ); +} + +void +LocaleTests::testLanguageMappingFreeBSD() +{ + testKDENeonLanguageData(); + QVERIFY( !m_FreeBSDLocales.isEmpty() ); + + QFETCH( QString, selectedLanguage ); + QFETCH( QString, KDEneonLanguage ); + QFETCH( QString, FreeBSDLanguage ); + + QVERIFY( Calamares::Locale::availableLanguages().contains( selectedLanguage ) ); + + const auto bsd = LocaleConfiguration::fromLanguageAndLocation( + ( selectedLanguage ), m_FreeBSDLocales, QStringLiteral( "NL" ) ); + const auto expected = FreeBSDLanguage.isEmpty() ? KDEneonLanguage : FreeBSDLanguage; + QCOMPARE( bsd.language(), expected ); +} + +void +LocaleTests::testLocaleNameParts() +{ + testKDENeonLanguageData(); + QVERIFY( !m_FreeBSDLocales.isEmpty() ); + QVERIFY( !m_KDEneonLocales.isEmpty() ); + + // Example constant locales + { + auto c_parts = LocaleNameParts::fromName( QStringLiteral( "nl_NL.UTF-8" ) ); + QCOMPARE( c_parts.language, QStringLiteral( "nl" ) ); + QCOMPARE( c_parts.country, QStringLiteral( "NL" ) ); + QCOMPARE( c_parts.encoding, QStringLiteral( "UTF-8" ) ); + QVERIFY( c_parts.region.isEmpty() ); + } + { + auto c_parts = LocaleNameParts::fromName( QStringLiteral( "C.UTF-8" ) ); + QCOMPARE( c_parts.language, QStringLiteral( "C" ) ); + QVERIFY( c_parts.country.isEmpty() ); + QCOMPARE( c_parts.encoding, QStringLiteral( "UTF-8" ) ); + QVERIFY( c_parts.region.isEmpty() ); + } + + // Check all the loaded test locales + for ( const auto& s : m_FreeBSDLocales ) + { + auto parts = LocaleNameParts::fromName( s ); + QVERIFY( parts.isValid() ); + QCOMPARE( parts.name(), s ); + } + + for ( const auto& s : m_KDEneonLocales ) + { + auto parts = LocaleNameParts::fromName( s ); + QVERIFY( parts.isValid() ); + QCOMPARE( parts.name(), s ); + } +} + +void +LocaleTests::testLanguageSimilarity() +{ + // Empty + { + QCOMPARE( LocaleNameParts().similarity( LocaleNameParts() ), 0 ); + } + // Some simple Dutch situations + { + auto nl_parts = LocaleNameParts::fromName( QStringLiteral( "nl_NL.UTF-8" ) ); + auto be_parts = LocaleNameParts::fromName( QStringLiteral( "nl_BE.UTF-8" ) ); + auto nl_short_parts = LocaleNameParts::fromName( QStringLiteral( "nl" ) ); + QCOMPARE( nl_parts.similarity( nl_parts ), 100 ); + QCOMPARE( nl_parts.similarity( LocaleNameParts() ), 0 ); + QCOMPARE( nl_parts.similarity( be_parts ), 80 ); // Language + (empty) region match + QCOMPARE( nl_parts.similarity( nl_short_parts ), 90 ); + } + + // Everything matches itself + { + if ( m_KDEneonLocales.isEmpty() ) + { + testKDENeonLanguageData(); + } + QVERIFY( !m_FreeBSDLocales.isEmpty() ); + QVERIFY( !m_KDEneonLocales.isEmpty() ); + for ( const auto& l : m_KDEneonLocales ) + { + auto locale_name = LocaleNameParts::fromName( l ); + auto self_similarity = locale_name.similarity( locale_name ); + if ( self_similarity != 100 ) + { + cDebug() << "Locale" << l << "is unusual."; + if ( l == QStringLiteral( "eo" ) ) + { + QEXPECT_FAIL( "", "Esperanto has no country to match", Continue ); + } + } + QCOMPARE( self_similarity, 100 ); + } + } +} + + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/locale/images/bg.png b/calamares/src/modules/locale/images/bg.png new file mode 100644 index 0000000000000000000000000000000000000000..4bf391ae66e75647b3e3d966cb4813a19eb9cc1b GIT binary patch literal 175122 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�>JhU65U$!HFN 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 +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/locale/images/pin.png b/calamares/src/modules/locale/images/pin.png new file mode 100644 index 0000000000000000000000000000000000000000..842fa80516c054dc8a430208cd3546df7d5f1121 GIT binary patch literal 6191 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/pin.png.license b/calamares/src/modules/locale/images/pin.png.license new file mode 100644 index 0000000..cc08e1f --- /dev/null +++ b/calamares/src/modules/locale/images/pin.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2014 Teo Mrnjavac +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/locale/images/timezone_-1.0.png b/calamares/src/modules/locale/images/timezone_-1.0.png new file mode 100644 index 0000000000000000000000000000000000000000..4fdbd0e3fef0ca2aa4834b053be3b8133f7f106c GIT binary patch literal 12010 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-2.0.png b/calamares/src/modules/locale/images/timezone_-2.0.png new file mode 100644 index 0000000000000000000000000000000000000000..439ce31eb6d143be0f0cde487ac75cd50f0fe342 GIT binary patch literal 5496 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 Q9t>uD()>i>ap$Q20yAT~MgRZ+ literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-3.0.png b/calamares/src/modules/locale/images/timezone_-3.0.png new file mode 100644 index 0000000000000000000000000000000000000000..dc64644fcd17b5e8f0bc6039f8dcff700293f923 GIT binary patch literal 15999 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-3.5.png b/calamares/src/modules/locale/images/timezone_-3.5.png new file mode 100644 index 0000000000000000000000000000000000000000..327ad65aab534f08b67e5c5d99a090f8019cc764 GIT binary patch literal 1176 zcmeAS@N?(olHy`uVBq!ia0y~yVCG?9U<~1428!%^?EMQ!v7|ftIx;Y9?C1WI$O_~$ z76-XIF|0c$^AgBWNcITwWnidMV_;}#VPF8MZ+OALP-?)y@G60U!Dpg3C`_%67soCXSzSSdpwd1<~8HtN*YxnxB`kD|i-8pZq=l*|1 zvGc844*Mr8bjVrj_U3=j|Ns9PfG)9RV9;S;&}LxJXJ80tU@&H2Fk~oM?hZ5xh^lvb zmu&$V2xPY&3+y-%)DA=^gMjS%{l1xtTr0Nuv>XlSJQdu0#6O_Nu57z!{XW0$v!Ot4 z@kY0JsJSCqHL=t(7!;Jv>pp+ zKI{)Pr~7OO(7>Map+JkeP6v0N2>}`i^y$?n-&O(xE4n1eFBnMU2I9`{9vZyC>+U^# z_^6JRTaySb-B8wRq*cO2>qw8HmAcOIur;B4q#jUrOw}v?f zGPqo1IC!_2tsDqAgev%d|DWH$Aj)f7{yyzjd)d|6*rjYj4}@ctA^QwVT`VV~Z{N=Etl3aon*gTW$F=_Vh>k z>o@Z$hHNeUTKt*YUbCZdg07zK`ZdQkN+jggD)${_=sfc5+BOM=^z?Fn9tAc5<_^Yx z9f|)ReDL65dH3UqZr7sAn?HAJJpX%1wdK{TUuu(2-nnYr_m3w+PFFw7_T~sp z4ldT3nw6QG?O$8WNv=2Prtkk{_La&Wa~dm3y?}PAmbgZgq$HN4S|t~y0x1R~14AQS zATlrtF*LCgcyqV(DCY@|S5_jL7hS?83{1ON!6 Ba;yLV literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-4.0.png b/calamares/src/modules/locale/images/timezone_-4.0.png new file mode 100644 index 0000000000000000000000000000000000000000..da1e73735a3ec08d13659cc69ca04b356a109650 GIT binary patch literal 17925 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 OBs^XHT-G@yGywo*;s?(F literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-4.5.png b/calamares/src/modules/locale/images/timezone_-4.5.png new file mode 100644 index 0000000000000000000000000000000000000000..8a86b67a859416cc30c1858eba919b972dbb42e9 GIT binary patch literal 2823 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 RvylS%&C}J-Wt~$(69B#5+YSH# literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-6.0.png b/calamares/src/modules/locale/images/timezone_-6.0.png new file mode 100644 index 0000000000000000000000000000000000000000..78d8dd7b5cc2f303d8fbf62e22c410963ab3dfb6 GIT binary patch literal 16804 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%0ASN%9 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-8.0.png b/calamares/src/modules/locale/images/timezone_-8.0.png new file mode 100644 index 0000000000000000000000000000000000000000..81b518e30f46431b9ac45818a17c25e18fd67b20 GIT binary patch literal 8291 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_-9.0.png b/calamares/src/modules/locale/images/timezone_-9.0.png new file mode 100644 index 0000000000000000000000000000000000000000..ac2ee78746c1063ca6ed17dce03af39bcf73b65e GIT binary patch literal 9735 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 RvylS%&C}J-Wt~$(69B#5+YSH# literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_0.0.png b/calamares/src/modules/locale/images/timezone_0.0.png new file mode 100644 index 0000000000000000000000000000000000000000..f098b9c67e3701d81751523d2be9ec79911c9099 GIT binary patch literal 16289 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 QMUQBruA&9aRknQbAAATcvj6}9 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_10.0.png b/calamares/src/modules/locale/images/timezone_10.0.png new file mode 100644 index 0000000000000000000000000000000000000000..9304fa4ad9e1fab49575e0c98223e0e843c67551 GIT binary patch literal 18305 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 bbP0l+XkK8=Iv_ literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_11.0.png b/calamares/src/modules/locale/images/timezone_11.0.png new file mode 100644 index 0000000000000000000000000000000000000000..b5395bad1869cb8d8466da11e6c83e89f1bbed2b GIT binary patch literal 13590 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_12.0.png b/calamares/src/modules/locale/images/timezone_12.0.png new file mode 100644 index 0000000000000000000000000000000000000000..04902676030220b0be436197b54b2115ea89127e GIT binary patch literal 8116 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

L)^)5+z|FJ%I2Wyc3wL1Th15qcZ4* z-Wn@(uQZ7@yFB0Uiqy1pLJ}w>@mT+#`}xSBqoDb71I34?LbW}VBhJtKk>Oo`X2sSD z@K0kRB90etJ<>;a(?z?g=p^FP>^RA;qH(3NV{`YD0*4pWpvh_FV*vi>`>BLo=6$~! zFImrtb)pW-kI+Lp;5_^vj>EBfEFL9>3%;D(YT55uwLTN?&UzKW`PjR^ETDlRCgjr%-A;Lu_`X#nf2FOe!a&c;63ylx z6R)1M0wR!s*ZTMWs`2 zclGV`rcWVQ2hmj+!;_f!g#ZE)RkZAF zTIr-2g-$Yp=8g=49%H&R%rTl(ZWU7*Iq<5$XMsuV!j$D?d`Rlf5A1*QFFIpsvz@C{ zU&(=lOI{z4#G96d188%gb<0fK!PLtcg+{9OwoQCa%lxb}BVRP|0th~~VdgVc+Ig?0 zQKPZvcYFDnE!-o(BlgFXFp9oTP%wUf0!Kd>8}k56yA`3mqIC9#0TX#XSFh_W&;&Gc za-ubEtt%4kNu&TWhpj6%*Zn;K|9;TNHmY}_2RRZO7 zs{i>H_nYfZe2gWIV6-uu_3`mU_#P;-IE%YmuKhb1j?rbo4y>mW=bGZ_`e$0lj^~&l zH|9&Sj}GSi#DAEaCi-#Qso_xY3r(R1^K@&ex|*&>zP(=JhLTm?{4EGZFvKClmhXfr%XsO;BP@ZZ*3_ zm8f~D6&8uzk76$^+8&Rz%oq1-2S3Ib87sjlx(JIgQS*9BOt1sU_=Zx@)P)s5C|b@`q^g7f6-NLSY45wr_A_P>#n2y_`vWgdV^XT1iyb{K*~ z?1qU&DXn(pr|60C^upNy$78J08z5dkVv{sVa~ zKh-eo?c~x#=p#Y8al2-B?LS8rlfe_d?A~2bT7Bbv6Nc6fzIYYZSnHC)f}ef`K{T$7 zw6`rz&+OHTYE24|=jV|fna}`?xv{RW%I50S{{3p3d@>H8>OQmoFGbgfCTcRi6{}Q? zv=>lBMv;A}vcxvC_y5bF+MPB_zrZgvA*}-!R_EHC9aUY4NqK3!ISb(C#d@Bkzq{e$ z^#x=lm{StRYOySMu_ygXPv>u4QS%}^mM1JPrscK`qY literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_5.5.png b/calamares/src/modules/locale/images/timezone_5.5.png new file mode 100644 index 0000000000000000000000000000000000000000..172ad8127ba74bf08c5c5f7f92b42edf779da249 GIT binary patch literal 5413 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_5.75.png b/calamares/src/modules/locale/images/timezone_5.75.png new file mode 100644 index 0000000000000000000000000000000000000000..1bc2c70507bf9a1ebda72f6b96d968cea48e214c GIT binary patch literal 1387 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 We_@llDZ>l~K~x`UDwX~L4g4=Tq!5LkZI-%1k9m zPT$NFjl)D3ha8f#$RWl#hbH#x+kf`I{cr!--+JG5t#>`^d7ic2dtLYa+;MiYlMq!9 z1pr8#wzqKsV3#-mLarivA1prJQ0ssr6vCf%63wtj)*x7*Xo#*j8Mh0a0TbR8U z0a_^7=|Z-Hy3mKH&uJSgSMry+!RuFhZYzCQRQb>~O2Zwzs%^BWsKHVv#40{GuxWO- zpYdVY3`?`N%7)2~2a1)KuYfaReT_$3)a7SfZM1G5J{-VQFW36i+G@1@FrlGU%Yn#~ zS(}}1*N`k*Y%}QZ)!d+8$v6@7cKhczGY&e;U+w<^fsvTGoYV2~UT1H`t2^T21(ZZl zVF04)1AgwcP5HVRh3{@0YVvcD0$}71X+v*CZwzr`@?+IRW>)AuoB6AXg!AEOPdNal zc&ZjO<(A>>gG93@&ef>NZw-lKcCcBUKpl#Psa}~bG2tWt?Mhgs-Nz2rag;1nKGj6n ztt5P$GoGlREJ+!bA0P7S^dY~zOp12stE4U85(crMf$i&r8*M|G_kGwp+Z_#wUrV~G z8ivmdes{PhtBl!9uJywP3o4kHHzlqRXIa_b51!hW;ZA?VUa)*Q9e;CS5%XGSd*e-O zU)D2-s)M13r>ok7nN;jxKKW-8_aqe~s7RVKCS_d}Uqy@UB&Z)7r3SC<9~f@`oxJAD ztsPy@(Jp+A7vXNil2dg|)QVONSrUU5r3;6W*K=6z*iZgA$>*%-55 zMW2+LMLH+FrFUno$U?xeSf0PyeX33%=-VlzRK7=HLVKH3YJ+~c>x{9YQaLTyxj0i}gGcgjktmhh%b zDd--@WJzvxM=#Z4YT^62{4>@i{Ro`DTV z2im(i@aYLZ>Pyk`V#g&DYsU+AmBNy8t!+nhIFs0=_?rwVnvt_N0PE$sZ&aVe_&bze z>)^y$JUvxMVVE3n*Z@ z>d=AJZgzxWZ`HA(bt#{uE?wF5=lKilnFnuGctf=N81fp58q{wcw}UU?)5kQcF!y%C zDfG8O6lKZ1ko!J2Ptat)Skpb*>g)baGln|=Ph07aNso4K{nXJlC8vE90HRy|7migp z!RX;TUGLXZmYn50eYpdC9r7z8uIIjz?@Hy;?M17_8T+u{IwK(=@Yqz%+oS+LHx#$e z=|^-z!jU^`8l+PyI@H60)!L0Cad*2KMb4mhLDVRW2;jq$r5aMVnkg_Bj|$DTe6QB* z$(BJ`=^ZLO8I#B_XZ3telNn-%8Epm4Y`OF*#5|9%BaYFI*HV+*1OBE;%+QwJ zJKJQMqaZr39hy>OhQ|;aS|an$p_Szc(LwrJMfl|HiW?q7o1NUok**6<&lMmrWb!H6 z<{^S4>3Dn$f*Li=U0CY;W*^o0ZaPn@dZ5UT5D|o!Y-+rfP+8xMVlQ7iP}27HGZL!6 z?I|{Vww9cEa9HZ7OihR8ld;@@yV~r6-cAONbBl6n4~{M&N-tK*J=d#1p$FN8uFguN z>B#LwI!f4!i|}eD1TtF&(_0;|mj2(%D#=)~=ZE>aP`0xg0FTkBOCf#D5YAa`L8919 z1FEysX{c_;C^&z%P&;pG5Hfl*E})1sY3^BS&ZyCVKwtz8R#Dt4#R@qWhhw zN0(dtA}W7_woK%c5h3gKZb#a9FPZux!zdSs?u0a(cu(M2J1SgJ_bHad%|m zrIOwXZU%$`QWk>jgrd0VlZnXU(yZ^2+#$989O|P#eV?aZjxP42uD|GvOgog}QJ7Z< zC2m*G7KG3A&_iu!9_-YxzuJFAK(M{BkLFY0j+k)1200%i+IkZGE)xAsa1s8{0Q3;L zdT{7P=(+0Zn;?x$^ii4!T@wU?0`rHY{yzdj@P1b=U;lRi!Wgcn>xw{_ppYgAq$WZS z3Wyye=|BPUJ3FB9mx-i{5&nQgB57X<4JP0&hWTsbBLZ^yW{^q%aN5?%=GDmyasL7$ CBEw4n literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_7.0.png b/calamares/src/modules/locale/images/timezone_7.0.png new file mode 100644 index 0000000000000000000000000000000000000000..bc2c8f1cecc28db38af2e1f4f0d94c6ee65cc8c8 GIT binary patch literal 15203 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

hnS@lCN9us5-f6zaP7 z5JNrn0(*-S;?nbxgjUSn=FBa$EJ&R-nVHdjzikib0cEJ?byLXCIV!@o!iVNtIA|N@ z=~(N;>SSo@l*T*E;gh=O?Ge=z5gUICO#{5xv}r{!Ge63z?! zS|&iq@z9Dw>^5b{qTR(Qa0NG%G>|trBc^6x1$92AE;PWo?9HW__hV(oe{`gF6qBB6 z?=$yfR++;9R{nYP_IBk$7rMV|PRx&$Mn79cdADls>m9f6%${8I+W+=50NgguJIUi4 zt-MZBNsW$-$+;hhF*xT1#imoy;a5blCBb43A7x9qYq+h^nYHId$H0mlWoLM9cj=RF zoKdFh_F86|5f|{QWz2fjCqS1TB*B9u%)o#!?ixf+Pg#1Ca}>Iw+_s19jt}`aJAz~P zDc6-52|`C0)0k91myv2MBu@p>y*Kl2XSr=x0wYWNU(P0%7R#RwP~V?`;4x^& zE`mIlSsvo?_7ztu_2Jg(hn;OPVp3S7;x9h!YfAm+1P(q=XKNhGP$2y>n5jrk?L~&+Esa9&Gmw#{@eiEDOvy=O}pK?&`V8{jiJMoj74uVg%yf^%HxaTu@i%A zTinFu#E!fB$Sd@F+|`pC1v7CEpVsl%G~=(wn%_BA-wngym?wvFq!ARS5%62U!q?FY znx#b7^?-dQ7SngQ4IcNOl4dUu>VBA{M(moTy|%lX?j<;W*#2z#Vucqi&ju%KXIu{o z1{|lrqeJ&oP$F|EoNT|4!kyx)%wUn*k+%M=Jo#5QcMCk!~i{2YAo?v?}l{F(>R z)_YSWiWJiuWfkH4xL8)=_LbEZ`|j)U8vz(2Yh2+XD$e#zfwXi)FM5GRxA}vdl38YU5sw3|&y7jDDrKZRY3%&H*RMLLrz2(bAhz2`U2O z=fv)i8c^qr(#mAsXnMezNu|qO1B=92$giN^*R4{%zF~zZlulLsBAdnV64o0)w>vYK z=bz^%lNry~m9u~Rx=7S|?;0GA7}%A5tja9i<=pEb_7B;S2l znp{DfHX!FFV$klmz+~yUV>}Gl(+3>Di3{^T_AKNY`^`|z?|i8^_O|SSlu7HAJ{q^jxGj;=@1iH9RX(>q6p*P zdi$RZ_0D!)*=T9C+MX}8asj7rQy0};To;vCqb$jKOKAfmq8^qGoGCO{N@G!|rFxZv z8sGF*XZKTtly4r@RRl|X7Q7pq#ywwrrU0mfLwE6$N;B>I-9-Ir(ff9Gm8 z-li$|jEJ`;#HII_SdR|G?jz{Z;tdGBRC?~EA+P9YMcElByOGrguSy~n+*Nv9wK z-VYb5HBXD?j@8FoEC4e%BVLgJ0WX5iv$6k&*1asgKf|Ts?T8DUKA4$=_p|Ej;u ztW(MIO3+hBT5WXWt=<`i663AW)%Pz7#(o*{2>mQuG6ByWQ9*b=f2Y_}#!sjTs)Azb zD^zpr6OZw9$-oT-5bwLP0`BSxDv?F&Hb-jaeMQl6?VF?(3oKS$c&|Ne26 zr`in0FtJ_vV#EpeU`M|EWd0>imxyPqWjEh**#u4Aam(Q@K4@a4qz(V@lM$qXOjn<8@hzE< z-FKLJQ*8bQ%clUSgbm{9xe>HmpdI~#WU{C}?~VIRKaQF3IwEb+EFj#x7m_$HN9H=P z<9|Z74yLMD44wVnJjJu0JxY82DOzD?`w}%YWOd`+p{9^Z#e0!X#wn*QtPC+o7{INy zj-B8d>sc7l@{5>CAe0#^P*l*@V1DSQ3D*0n7LG%KW=e;(oT>ePbwRXSV&sSZz*-= znqx!%%um#7z}nAGahnC^UCRxNFBi4JDPX0KZ&7b~gK&(@T8zFtY~{?mDCj1=E~P!IgQpV+smAiIE&rOacmy~T{JLWf&E z+hBtWdVNpWWN3&Ac}IZDwOR>i(U1O0w5rhtX6>-g*6XmS3FPucbIk$4? zom(_+vX3Xl$%q`N%Xp2!)MONpyBls=T(YO637>1X{w-N;Lfo-k~LVR5}hdCnJD^RsoGCqZ!H_yR~KlHuiJB!>-4-?Yc8XCrG z(Fo#SnVTG{8VbhnB$Pm%r6Cul+JXP$GKf}36#3=l(J;MvMAcLau&c=+B-a3)cZ~aov>eT~Cr)?mjqbL20Iy!bqtWWTs0*)(gwV z1~Fz|n!l)^KZ$u}_F`J#SIKMM?nKe#?hVb;uQp`iyxl`u5bhD6M#Eh4<`W6lH7P?` zK-M%kE41!h3eSN)KCTQ%N|%B91S}rwX|-sBxJs0L-g+;H`DyK1TB;}e>D$G4sq-_Q zaZfYi(BpaGCe4LvA&>0z&l(Tyf?CB}fDAr7`Yhxh%+6kvx-SuToq(UB|mpJVurHOJ>`Z)N7V` zg73O>uMm3c3Rj}xrM+G#K`&My6QP_Mmh#3yEE5S&1bQ|svZ8fuT$mk^?~D`IU=6mwtHQiu_P zKbn2Vid`V93<9<~A<+A!`feT`8>e^0>W@Bkn*|)kp9QC`4o-G-h1q+#R)Jc4WE& zssqa3AWU=bELZHd4`s5d=~r+^c4g~k3P|ICYIV1wD`!6$59d#v&KufHL_MnfOQ4e@ z`2eU(kN@}R`ndo`WzizbC%3ym7r>-=lO&ccKE8RRZp#ef;wsFreIxWKAsa7ghDwHc zbYDf3KYxFus_iOwD#JqOJYB3@j0sNvwsI?clBHkzLLX{ydblaysvu>wX3#NLOEZMKJPFqo7gnK zK)4N%UELy$@eqvtx57HFI3g9oc;TN0yQTv%d3^-E2N%0==#P4?r4A0iRMoee7j;sC z7%aTJ);Nb&qX`ap2D-t;?m-tG_Ru9A5fArE8=0_mMh)}S$lF7BpRy>Uy(XN|O0+%D zGT%>Srtj%_)sfn{&7K=?^9Vy=;GKDLV5!!&DI^!hyzp^Dqq3I4NM~W!Ou^_-*01O~ zawX$BOD;u^gLR9nda4>I(Hj3P`+1@U8f-638|~tyjgi(I@Q1hvIbUoxn3MgM@zDc; zHzN0{;)1=MRbUO5b^kEb#`^5*6q2NjH`C0@LQX&gGd2!y=3Q{ssQ2YXKf380L-P{E z=AVi7^2HtHHkASp0~_l5BJCzQ*77ditfHa4I{%hKzR(HICiOlWIb%caw2N9y?V#s}-HGokwl z{M18j$Bt_Prmg|bk@urMcAv286eaiNG`M*GRR;9VrAG`TV}ldi4d&|U@z5hvZOANK zl3gncBOyRkp5&hW!99$#B&C_6T6|Kdt1(Z)dSC7uZVrHoE&g)t6G$)M&u><}2;MVT z819q5rrKeQygt2V#X#Ez?N{@T?deE`UoDmuz@!W3C4cF`2w%M!xUeR z(J5G9g*-pUq+9Jdo9w2#7lu0#)^s=L1UeU?&0&xHD2&H46pqzt|j4(~TDjv_egKAgfv-SkOnn>Q+ zLEU^yb$HX3^&bjnmJWE7(sAu`_5bweKBHq5W@Z!rLO1eKZ9c_WMKb&{D%!>fk>JOF zk`TNa3VN+xrfHVfkOX^$pWR44y-PBY$*5lXTL=#^A4bsWJqo1u5#@Gc)pMqI@T{4} z6`yVV?a^H$d0cB0O`$bPGjt*L_$ItZn37yuBlMsdaCbhJ#fD?m|8|a0$OIPW3{u;pVb-h ztfioduJiMySWnH7sA*cnO*&9I4OrG}oBtJj1j^HEem#g{6&Wzg%{g})a_$f?R zRANw|soyfwO%NFKa>6zUEjjEydh&M(l zuxw*3Sn03e-;2`F?@kN{CH^OFYNbitoVpx}q-oy>&qr7JNJzLdQb&#&e4hQ)u)%=L zeOO+CYiSZ4$4e~iWWV{MS7h}YdLC@7o*I+O-(uJiX|=rPvX_Nn*b9_`73a$)G~*%gs{Qz`rSFKL`U{n9=)sJF#mAEFD`%DZ5>b*VPDu)giJG1Fd2A44X$rX6&&1!D4 zz*4tY^&Z`)D%ud)xBjJ}ha+9iDcVKqz9b8AH|O1$UgTf<7=#sU?!uPJ4mJmn6^bqS zbjTfr60mF#?D{9!HDx>x57Q3n7-5KfDqzkgv``LUqGof`#{T4{`E)obn#4HE0^lnt zSJA(NL4g`VO5GI`?8{~@|7~8{mDRh3 zmg)_rshmsx^`u1Ic7a z9Kn9dj0Q0tvpE97ra)Q&9IsB_ZE6=bCZ8E^BJxKMIe}Iv)h1aRtOp`J!tL&2D?i?x zYC>LZTNc~R@UQ!Uv3j^qApHpbd5NIf%`>Z~T)$jT{)OC7aOvE#}{SBNiwo zpaSK;P5pe&`Un*|Y={%AvVH&7zgA(WdBc#6r-%ZJ&Ln1IxeQ_U7n&4qTz$TB5F^aF zPe_~b^`@}m)wX7U-AuzcT}Ag_P6GbWm^4A`lmlVIambNIvui)Lx7n9C+evvRwfdL} zNrn5Z#QKh{{&BncB}Ue!l0S~2*E^&H{&%eYuXO%~|F7L3S-nMn5=>j8;uUyxk$m5e zzu~1V-&RT-CHJm%w7V(U8SAXpie-K7J-P}dAd{KMl{c=Z@mqHofUv=9YIZGhxuzk5 zB1Z)oh7Q9UhJ55;1n)}=^cvqFENiot1^!(%dp0gh0A;h@XqF~}*sIJY$Vr2_7oHtM z$=Qd_&0BA}ZaZhw-mjVmWq?uG zO)G$R{U_Vdkwc#|YIny^PfvOw&Jzd*F9nk&WCp-gGQ92TECw|5+3Zf}lYME41+)U7;NNJb!kKb%M8 zFN~_L_06ud(Kx)bDfe2V;c{&%o>gC`oU`8R%K2gsk2uo6u}{^CFVA4u`^6r!<3KEn z+)6YuEwQ9yRi2Xqz*{XHuO0Mxm$xk?d&(UC@D^yN zSq)RiKu6aL{|L7EsPlnKblQ-JJN%wudICF7McdCFmU$*0jLo4aQNzd}QtR!L*9rZc zUC2|4YRdG`X#nyQ#8I18VOZ9fiBYPhM$shV(&Esw!Qnlx7U4I&8&sKG@3};>up9KPX}+6X@@!va~C3^!el0R zyqVst1b^Lb<3Cs}2Y7R1B^w=kitfX_ew9vI+Al2C!JDc5k4yEn4h?E^=Jcnsy)Flu zn+~FR>IvkTHZ6Zfkzq7$10SKbig-a)$$5t7fOKl&stm)`BL6Y%n zVuGB%5hJ?^5VXa&2kSOPKH;F)>U}*xk~S%El{4#%rZwk`Nb3qmG5W%*=e)C} z#9IO=fv2|#bwqI}bvNy6)V910dq}sc73eozD9~BCG9V(E(iiP+{*oE;{3d4Z^w=S1 zPeF9U09-Q8zZzD$Z&*)TV>fdZThL5%z>1qP6G42xR#{ee(-H(7YI#?22dn@@@yB+2 z6YCJOw^U{mOtG+<1V|!rwJbXf*C#Fx%Owycg-i->TE^Q<_Fzf*#PGke&*R@>wK3tr zchI1aZY&h}{wEqt*C6KO*ps$Ys1Rot=i+Krq8=MI%l!QTKu( zRzK=fwM zG|SIV8lez-x%FQGt6Gu4XbOhw*M&4X9JsXhN}>!A^#iDl9oIE(qk7TVixp$~8bKut z)5ZL;jz!h_A)Qoj7Qfh5_kTe1{y__MVsM#`89AloOb8*t$z*-y#im0sqzt% zas#WE%E_`b=c1H%lW<5MHm?eiaQWcY0Ayz_YCs6#m)e2L`Snr%R7TWpKo2eUWOS z^~}cEolyVB?RutM^uiPc0f?OnsY8Ms=3=S{E3v+SCQMKqv+sc8n{2RaNyN#oUNP_U z<%oXSy1mt;QTidf5D3|jX63X85Kn#zYAW3};CWjI?@Nm#Z+^KA7AQBClROC8q5z;z z%_%-Cdm6yDKGXn^kG8@```IgRIFUA{nhLl#aGnTx zR6AxZ))#uY~s?bK`Y4AaZXAi!p0L8z|ymVb2qJNYID?dCT$yFCCN?MZ0jmoFIY z=zTmhHy=97iOYDAr$^sF4(sk$S$SfPG#Hjr`d*%ik{OxtK#e15ybo@+sj(i{_EawF zb5;8!ssOQ0^V&&vS(O-AW0ARXnvYNHl>Lc2w40-G z&?AL)^CWm#JY#w63vheG_qvHPK0UBmh^V>8!=vh~9lcF&m@j*cK4kES%-R=2kJi&X zDPzi&7P)+u4(ax6@VxfLWL2I5LKWb$g5(f}4k1S;qXV>$%DlP57HEV*Mo*MITPDKQ zFPh~A*QxaF*$D)0+vGK8r;t{0dM#0>7~S7v8deSvTgMLQ zM?Rd~GAXcCX4Aj?^RDaI@XkkPy@5+EW3TmdJRaI#=T7P|+IGaJ8Ljhb|FCi3Vu<5u z_f`Y@tSje>J`NzuNIe*-nZhUP>sQE6&Na&Lx%%YDkdK61m^%AN6Sz)dU!K#w^*Meu z3F*E6lz0@F+2H18q`!Ai4cuZ<<`m!gRs5Qlu0M1pUTA@Sz`Y~2^@++S=RA4bQhh#^ z3V~txZHLx!5#HuIhw%X%g9HK5gL8cJ+nO>6bq}03b}X>)A+rEzzgPZV7_>y%Azr@l z&udnn<3!>YQ1NoF32;+Z(nM{G?TlIT_FURj5wEwW1aXtM#x0^7o&NBJY#FVl#mkd& z^N!o+8ms*ZEInq_AVS`zA!~Xy4e4Ru+Gke~_uP7hPiFyJP8$^4TpnRJ2_o+=L?m=XlOIw^ zYsYEUpVg1Pd}Kg^1)D(|m`hA!zkNTn-OYrY`2P4(4~uXfsyaW5yhs6;RA9A{7N^DW zWWAHS3|>FGkgw!rzKNM+-_gMk25KCRJSTYXQ|H4#u5tVmq?*+)Vq$v}RBaf5~s=u52AcnJBdLqFs*1din3T^dxH#^i-fF}3~=@*l*}+1 z`{J9mS?w!g8$Z-)^?=5`s(tE43Aa?HB5V%cvo1ZQ3Pe%wqf$wEqBW3P!s7vU(gE$a ziqNO>v@yd~h(vOr+**~>_?lUsKc^;A^t(V0)qJ|fJtzk%b3^*RABW&NB6;2n661V? zUk0&?5w6lF=GR$>(LmNr{MiiW{xRw(er2S-MY1i2X*B`JTT}l&`;xKE>s2}o8fRM0 zX{ORfw*LR)XGy1E(;o;UWCq)Yx0~A%gDTp#P)FK^trj52%s^|n_0l76BZx`zQ}7tI zkChR*UgJ5*oZS)rMS37@BaF*$e)~nYs?l#QR->hN==w;SrOwBwf&ivO`MUblayMU0 z+vIhCI&J|(8-nTgbbNXsMqx(7g8II!=}N}LRBmHA1M z2Ufc_Ln_X_NzmuHOB8m92LiIQlC8vo!n*~&V8@f^Av$_8ge@&FjsD2j4N`4*4D1M- zn;srWc%O(PeMhp9r*cDLfHI*A@rE}H$3x>*@pxfNt0vvn2;&2~|| zw&wmH95|9huW?iM;)q3UBX6sroED&!I3%=5z?$t#2__eMYZiJtUAq`a!3>D*xvhQ- zdLFsXl2>I}e@PGQ0wrzbQgASrzdllv)Z1ZWoXzwxAhw1wBFljgL+FuVf5dzmEX<|Y zEb6laSV2FQbBMv&WiKEnqy}&97d&L(=9siXRrjw7ui z0n^t5Y}75gxJ|W8*Psw7~to7^J_SF2Kzg#r#&mND8KrwL2#~5qq=&B509C)&dscz0md0TAi z^aH`tL+!NZT=y9u$ZzOTPmT=3Y;{#3zp_pOkT|SYI3BDcJB)?N9%ib5*^$Yb)^Ktx z+@QcFSmBJKk1{Wv^=!0nRtXvh@*N8F=&CQ)6)1E)LC?B*j>Nz3%su}Su<YfEd|>ii5c;$jI3Y1q1@ISlQdSm>4;lu{bzeq#Ov50F6*&Bo*J4iW>&}FX~CB A2><{9 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_9.5.png b/calamares/src/modules/locale/images/timezone_9.5.png new file mode 100644 index 0000000000000000000000000000000000000000..bfc9637ac0762e15efd2ef9a78fdc701e12274ff GIT binary patch literal 2964 zcmeHI`#;p#8$XiBt-h8_MpU~lC1%_zgIt#IQlpSDdC5#hNYl{BkV_(3)KDuacQ&>Q zRv4FYYm_Co5s8tJNh5Mi?xXphX@A-M55BMOYtQF%KIe0u%Q?^Ue$I2AgcDdhIcXJX z0Dzo>y^S*f;<5mUxk_z;lmv5TD;zfaS{=6npe$KNK$L)UF{-njH7My)oq&_w-uBMN z0l1q(hM@ToOQIb0YcG}TVM1mq-+he$5SCA zU)05HhxOr)G|9on$~BZTJ&X^$`*CN}yfUKlY)X(At}vt6PYK6Sl+q8kR#50N*}wPh z!`y39w{@RMT{rgdn6lTk)*%f1)Ldw9J4m{nVi{0mc&Z3nR9XDSA4O7itbF;^^!^T= zN*D6yxVG6t3nYHTuM3`LxN(bFyfw7t-}Wy8{}%}Ojn{J161L!Y-j-TJt$B?f@pA5DAK2#6}p0*}?T4jHWrt+l(Q<5RT}dJT~dlO`G1)Hr`9 z{*7nxH_;LPjV|32*_@3&h|ctYXZxC6S;{HbntB4aLhFki{mkv9)iRyl^<&&SG~Zxd z+H3wh)X2B2(uab%svkK#BIgGQvX;WB@GUpmG8U|Dz3Cd8fMxjQ<;kz9TanL7vdg^L z9PuW=M?;q<^cdY1VycnUEG&=XH zUcKD2P{!BZy>y(Dnc>x-w;+q$45CB)ItnI;V_zy9m8Vc)PfIj{cKTgLgL$b38K(9YWR9BHes5 zM9cE&l8tJGvxrpYO5jwHy0PLNmm!6#RDKtWg1yk;fbzO`-y(|4$1}D`YQM!BcC=C`hJi%T7Z)^3-}cKDC_6}Bs&rU*W#m)!63z^CKjz>Js6BamJn~aQiy0d8T|$*@BJm^j7!6?^r&fE4 zoTZr7V06HB8man9t^Lz#Q@QQKQ{8t{QD%q7)=wULTIx?1o`fJ-O1>@?ybA36qdUIj zP~r8NN5ey!{=5%^c?=UZx5gv;Z-)MMwd!`e-N0gXa7`f$i+uY%uK@Scu5 z)ORDJ%PIC-_eC<}5^fESa^>(u0jDDX8adS_~Jm`_r6@`m0O0r$q{ z<|{}Em?9Tm7iQ{x=qF`cz{AB^D@d@=^3MI{&LfejynE#|rS9}PjYH)OlPJMR=J)9P zb?;Z9vP$|BPn791X^W!+PWjsp*Ssf86t3s&Z&hRN--xLRm$W$3+ZV!`yGHnh$FotC z)gf<(NXBESAxfxH&Y_k0eI5%X7szC}`Pzo@&S95`!2T}Qmk0b6q)@>4O&h_bf1^FB z6oc5XpS;hH=`v&U$LV` zPqCAk7r=?cWkoKe>tL31*rpNdaYL-RhZ`8&MO1aon!Vr_CWi>KfxeI8mXl&y?o=H$ zrp(e9G-uU#nO9A(Wo=uO>CJAPt^CAJ!A)k0(x)h+R(F=At$47n^2LY(Orj=9*Ndo6 zW*d*JaBkDP>O$bna~=uL8}|MZDXQAPPburDkU)6#i;s9$)qenZFTO)nTW*_>*A}V4B_B8d1GK(@E_~4juKGq8!^0RO zV;!_U28|9^_F?=(flML!p9}j-0d1mdpzn%CV~h") in XML, set the +# selector to the name or tag to be used. +# +# In JSON: +# - if the string contains "." characters, this is used as a +# multi-level selector, e.g. "a.b" will select the timezone +# from data "{a: {b: "Europe/Amsterdam" } }". +# - each part of the string split by "." characters is used as +# a key into the JSON data. +# In XML: +# - all elements with the named tag (e.g. all TimeZone) elements +# from the document are checked; the first one with non-empty +# text value is used. +# Special case: +# - the *style* "fixed" is also supported. This ignores the data +# returned from the URL (but the URL must still be valid!) +# and just returns the value of the *selector*. +# +# An HTTP(S) request is made to *url*. The request should return +# valid data in a suitable format, depending on *style*; +# generally this includes a string value with the timezone +# in / format. For services that return data which +# does not follow the conventions of "suitable data" described +# below, *selector* may be used to pick different data. +# +# Suitable JSON data looks like +# ``` +# {"time_zone":"America/New_York"} +# ``` +# Suitable XML data looks like +# ``` +# Europe/Brussels +# ``` +# +# To accommodate providers of GeoIP timezone data with peculiar timezone +# naming conventions, the following cleanups are performed automatically: +# - backslashes are removed +# - spaces are replaced with _ +# +# To disable GeoIP checking, either comment-out the entire geoip section, +# or set the *style* key to an unsupported format (e.g. `none`). +# Also, note the analogous feature in src/modules/welcome/welcome.conf. +# +geoip: + style: "json" + url: "https://geoip.kde.org/v1/calamares" + selector: "" # leave blank for the default + +# For testing purposes, you could use *fixed* style, to see how Calamares +# behaves in a particular zone: +# +# geoip: +# style: "fixed" +# url: "https://geoip.kde.org/v1/calamares" # Still needs to be valid! +# selector: "America/Vancouver" # this is the selected zone +# diff --git a/calamares/src/modules/locale/locale.qrc b/calamares/src/modules/locale/locale.qrc new file mode 100644 index 0000000..713943a --- /dev/null +++ b/calamares/src/modules/locale/locale.qrc @@ -0,0 +1,43 @@ + + + images/bg.png + images/pin.png + images/timezone_0.0.png + images/timezone_1.0.png + images/timezone_2.0.png + images/timezone_3.0.png + images/timezone_3.5.png + images/timezone_4.0.png + images/timezone_4.5.png + images/timezone_5.0.png + images/timezone_5.5.png + images/timezone_5.75.png + images/timezone_6.0.png + images/timezone_6.5.png + images/timezone_7.0.png + images/timezone_8.0.png + images/timezone_9.0.png + images/timezone_9.5.png + images/timezone_10.0.png + images/timezone_10.5.png + images/timezone_11.0.png + images/timezone_12.0.png + images/timezone_12.75.png + images/timezone_13.0.png + images/timezone_-1.0.png + images/timezone_-2.0.png + images/timezone_-3.0.png + images/timezone_-3.5.png + images/timezone_-4.0.png + images/timezone_-4.5.png + images/timezone_-5.0.png + images/timezone_-5.5.png + images/timezone_-6.0.png + images/timezone_-7.0.png + images/timezone_-8.0.png + images/timezone_-9.0.png + images/timezone_-9.5.png + images/timezone_-10.0.png + images/timezone_-11.0.png + + diff --git a/calamares/src/modules/locale/locale.schema.yaml b/calamares/src/modules/locale/locale.schema.yaml new file mode 100644 index 0000000..7aa7811 --- /dev/null +++ b/calamares/src/modules/locale/locale.schema.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/locale +additionalProperties: false +type: object +properties: + region: { type: string, + enum: [ + Africa, + America, + Antarctica, + Arctic, + Asia, + Atlantic, + Australia, + Europe, + Indian, + Pacific + ] + } + zone: { type: string } + useSystemTimezone: { type: boolean, default: false } + + adjustLiveTimezone: { type: boolean, default: true } + + localeGenPath: { type: string } + + # TODO: refactor, this is reused in welcome + geoip: + additionalProperties: false + type: object + properties: + style: { type: string, enum: [ none, fixed, xml, json ] } + url: { type: string } + selector: { type: string } + required: [ style, url, selector ] + +required: [ region, zone ] diff --git a/calamares/src/modules/locale/tests/locale-data-freebsd b/calamares/src/modules/locale/tests/locale-data-freebsd new file mode 100644 index 0000000..281839a --- /dev/null +++ b/calamares/src/modules/locale/tests/locale-data-freebsd @@ -0,0 +1,79 @@ +C.UTF-8 +af_ZA.UTF-8 +am_ET.UTF-8 +ar_AE.UTF-8 +ar_EG.UTF-8 +ar_JO.UTF-8 +ar_MA.UTF-8 +ar_QA.UTF-8 +ar_SA.UTF-8 +be_BY.UTF-8 +bg_BG.UTF-8 +ca_AD.UTF-8 +ca_ES.UTF-8 +ca_FR.UTF-8 +ca_IT.UTF-8 +cs_CZ.UTF-8 +da_DK.UTF-8 +de_AT.UTF-8 +de_CH.UTF-8 +de_DE.UTF-8 +el_GR.UTF-8 +en_AU.UTF-8 +en_CA.UTF-8 +en_GB.UTF-8 +en_HK.UTF-8 +en_IE.UTF-8 +en_NZ.UTF-8 +en_PH.UTF-8 +en_SG.UTF-8 +en_US.UTF-8 +en_ZA.UTF-8 +es_AR.UTF-8 +es_CR.UTF-8 +es_ES.UTF-8 +es_MX.UTF-8 +et_EE.UTF-8 +eu_ES.UTF-8 +fi_FI.UTF-8 +fr_BE.UTF-8 +fr_CA.UTF-8 +fr_CH.UTF-8 +fr_FR.UTF-8 +ga_IE.UTF-8 +he_IL.UTF-8 +hi_IN.UTF-8 +hr_HR.UTF-8 +hu_HU.UTF-8 +hy_AM.UTF-8 +is_IS.UTF-8 +it_CH.UTF-8 +it_IT.UTF-8 +ja_JP.UTF-8 +kk_KZ.UTF-8 +ko_KR.UTF-8 +lt_LT.UTF-8 +lv_LV.UTF-8 +mn_MN.UTF-8 +nb_NO.UTF-8 +nl_BE.UTF-8 +nl_NL.UTF-8 +nn_NO.UTF-8 +pl_PL.UTF-8 +pt_BR.UTF-8 +pt_PT.UTF-8 +ro_RO.UTF-8 +ru_RU.UTF-8 +se_FI.UTF-8 +se_NO.UTF-8 +sk_SK.UTF-8 +sl_SI.UTF-8 +sr_RS.UTF-8 +sr_RS.UTF-8@latin +sv_FI.UTF-8 +sv_SE.UTF-8 +tr_TR.UTF-8 +uk_UA.UTF-8 +zh_CN.UTF-8 +zh_HK.UTF-8 +zh_TW.UTF-8 diff --git a/calamares/src/modules/locale/tests/locale-data-neon b/calamares/src/modules/locale/tests/locale-data-neon new file mode 100644 index 0000000..0f0254d --- /dev/null +++ b/calamares/src/modules/locale/tests/locale-data-neon @@ -0,0 +1,318 @@ +aa_DJ.UTF-8 +aa_ER +aa_ER@saaho +aa_ET +af_ZA.UTF-8 +agr_PE +ak_GH +am_ET +an_ES.UTF-8 +anp_IN +ar_AE.UTF-8 +ar_BH.UTF-8 +ar_DZ.UTF-8 +ar_EG.UTF-8 +ar_IN +ar_IQ.UTF-8 +ar_JO.UTF-8 +ar_KW.UTF-8 +ar_LB.UTF-8 +ar_LY.UTF-8 +ar_MA.UTF-8 +ar_OM.UTF-8 +ar_QA.UTF-8 +ar_SA.UTF-8 +ar_SD.UTF-8 +ar_SS +ar_SY.UTF-8 +ar_TN.UTF-8 +ar_YE.UTF-8 +ayc_PE +az_AZ +az_IR +as_IN +ast_ES.UTF-8 +be_BY.UTF-8 +be_BY@latin +bem_ZM +ber_DZ +ber_MA +bg_BG.UTF-8 +bhb_IN.UTF-8 +bho_IN +bho_NP +bi_VU +bn_BD +bn_IN +bo_CN +bo_IN +br_FR.UTF-8 +brx_IN +bs_BA.UTF-8 +byn_ER +ca_AD.UTF-8 +ca_ES.UTF-8 +ca_ES@valencia +ca_FR.UTF-8 +ca_IT.UTF-8 +ce_RU +ckb_IQ +chr_US +cmn_TW +crh_UA +cs_CZ.UTF-8 +csb_PL +cv_RU +cy_GB.UTF-8 +da_DK.UTF-8 +de_AT.UTF-8 +de_BE.UTF-8 +de_CH.UTF-8 +de_DE.UTF-8 +de_IT.UTF-8 +de_LI.UTF-8 +de_LU.UTF-8 +doi_IN +dsb_DE +dv_MV +dz_BT +el_GR.UTF-8 +el_CY.UTF-8 +en_AG +en_AU.UTF-8 +en_BW.UTF-8 +en_CA.UTF-8 +en_DK.UTF-8 +en_GB.UTF-8 +en_HK.UTF-8 +en_IE.UTF-8 +en_IL +en_IN +en_NG +en_NZ.UTF-8 +en_PH.UTF-8 +en_SC.UTF-8 +en_SG.UTF-8 +en_US.UTF-8 +en_ZA.UTF-8 +en_ZM +en_ZW.UTF-8 +eo +eo_US.UTF-8 +es_AR.UTF-8 +es_BO.UTF-8 +es_CL.UTF-8 +es_CO.UTF-8 +es_CR.UTF-8 +es_CU +es_DO.UTF-8 +es_EC.UTF-8 +es_ES.UTF-8 +es_GT.UTF-8 +es_HN.UTF-8 +es_MX.UTF-8 +es_NI.UTF-8 +es_PA.UTF-8 +es_PE.UTF-8 +es_PR.UTF-8 +es_PY.UTF-8 +es_SV.UTF-8 +es_US.UTF-8 +es_UY.UTF-8 +es_VE.UTF-8 +et_EE.UTF-8 +eu_ES.UTF-8 +eu_FR.UTF-8 +fa_IR +ff_SN +fi_FI.UTF-8 +fil_PH +fo_FO.UTF-8 +fr_BE.UTF-8 +fr_CA.UTF-8 +fr_CH.UTF-8 +fr_FR.UTF-8 +fr_LU.UTF-8 +fur_IT +fy_NL +fy_DE +ga_IE.UTF-8 +gd_GB.UTF-8 +gez_ER +gez_ER@abegede +gez_ET +gez_ET@abegede +gl_ES.UTF-8 +gu_IN +gv_GB.UTF-8 +ha_NG +hak_TW +he_IL.UTF-8 +hi_IN +hif_FJ +hne_IN +hr_HR.UTF-8 +hsb_DE.UTF-8 +ht_HT +hu_HU.UTF-8 +hy_AM +ia_FR +id_ID.UTF-8 +ig_NG +ik_CA +is_IS.UTF-8 +it_CH.UTF-8 +it_IT.UTF-8 +iu_CA +ja_JP.UTF-8 +ka_GE.UTF-8 +kab_DZ +kk_KZ.UTF-8 +kl_GL.UTF-8 +km_KH +kn_IN +ko_KR.UTF-8 +kok_IN +ks_IN +ks_IN@devanagari +ku_TR.UTF-8 +kw_GB.UTF-8 +ky_KG +lb_LU +lg_UG.UTF-8 +li_BE +li_NL +lij_IT +ln_CD +lo_LA +lt_LT.UTF-8 +lv_LV.UTF-8 +lzh_TW +mag_IN +mai_IN +mai_NP +mfe_MU +mg_MG.UTF-8 +mhr_RU +mi_NZ.UTF-8 +miq_NI +mjw_IN +mk_MK.UTF-8 +ml_IN +mn_MN +mni_IN +mnw_MM +mr_IN +ms_MY.UTF-8 +mt_MT.UTF-8 +my_MM +nan_TW +nan_TW@latin +nb_NO.UTF-8 +nds_DE +nds_NL +ne_NP +nhn_MX +niu_NU +niu_NZ +nl_AW +nl_BE.UTF-8 +nl_NL.UTF-8 +nn_NO.UTF-8 +nr_ZA +nso_ZA +oc_FR.UTF-8 +om_ET +om_KE.UTF-8 +or_IN +os_RU +pa_IN +pa_PK +pap_AW +pap_CW +pl_PL.UTF-8 +ps_AF +pt_BR.UTF-8 +pt_PT.UTF-8 +quz_PE +raj_IN +ro_RO.UTF-8 +ru_RU.UTF-8 +ru_UA.UTF-8 +rw_RW +sa_IN +sah_RU +sat_IN +sc_IT +sd_IN +sd_IN@devanagari +sd_PK +se_NO +sgs_LT +shn_MM +shs_CA +si_LK +sid_ET +sk_SK.UTF-8 +sl_SI.UTF-8 +sm_WS +so_DJ.UTF-8 +so_ET +so_KE.UTF-8 +so_SO.UTF-8 +sq_AL.UTF-8 +sq_MK +sr_ME +sr_RS +sr_RS@latin +ss_ZA +st_ZA.UTF-8 +sv_FI.UTF-8 +sv_SE.UTF-8 +sw_KE +sw_TZ +szl_PL +ta_IN +ta_LK +tcy_IN.UTF-8 +te_IN +tg_TJ.UTF-8 +th_TH.UTF-8 +the_NP +ti_ER +ti_ET +tig_ER +tk_TM +tl_PH.UTF-8 +tn_ZA +to_TO +tpi_PG +tr_CY.UTF-8 +tr_TR.UTF-8 +ts_ZA +tt_RU +tt_RU@iqtelif +ug_CN +ug_CN@latin +uk_UA.UTF-8 +unm_US +ur_IN +ur_PK +uz_UZ.UTF-8 +uz_UZ@cyrillic +ve_ZA +vi_VN +wa_BE.UTF-8 +wae_CH +wal_ET +wo_SN +xh_ZA.UTF-8 +yi_US.UTF-8 +yo_NG +yue_HK +yuw_PG +zh_CN.UTF-8 +zh_HK.UTF-8 +zh_SG.UTF-8 +zh_TW.UTF-8 +zu_ZA.UTF-8 diff --git a/calamares/src/modules/locale/timezonewidget/TimeZoneImage.cpp b/calamares/src/modules/locale/timezonewidget/TimeZoneImage.cpp new file mode 100644 index 0000000..ad772ef --- /dev/null +++ b/calamares/src/modules/locale/timezonewidget/TimeZoneImage.cpp @@ -0,0 +1,200 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "TimeZoneImage.h" + +#include "utils/Logger.h" + +#include + +#include + +static const char* zoneNames[] + = { "0.0", "1.0", "2.0", "3.0", "3.5", "4.0", "4.5", "5.0", "5.5", "5.75", "6.0", "6.5", "7.0", + "8.0", "9.0", "9.5", "10.0", "10.5", "11.0", "12.0", "12.75", "13.0", "-1.0", "-2.0", "-3.0", "-3.5", + "-4.0", "-4.5", "-5.0", "-5.5", "-6.0", "-7.0", "-8.0", "-9.0", "-9.5", "-10.0", "-11.0" }; +static_assert( TimeZoneImageList::zoneCount == ( sizeof( zoneNames ) / sizeof( zoneNames[ 0 ] ) ), + "Incorrect number of zones" ); + +#define ZONE_NAME QStringLiteral( "zone" ) + +static_assert( TimeZoneImageList::zoneCount == 37, "Incorrect number of zones" ); + +TimeZoneImageList::TimeZoneImageList() {} + +TimeZoneImageList +TimeZoneImageList::fromQRC() +{ + TimeZoneImageList l; + for ( const auto* zoneName : zoneNames ) + { + l.append( QImage( QStringLiteral( ":/images/timezone_" ) + zoneName + ".png" ) ); + l.last().setText( ZONE_NAME, zoneName ); + } + + return l; +} + +TimeZoneImageList +TimeZoneImageList::fromDirectory( const QString& dirName ) +{ + TimeZoneImageList l; + QDir dir( dirName ); + if ( !dir.exists() ) + { + cWarning() << "TimeZone images directory" << dirName << "does not exist."; + return l; + } + + for ( const auto* zoneName : zoneNames ) + { + l.append( QImage( dir.filePath( QStringLiteral( "timezone_" ) + zoneName + ".png" ) ) ); + l.last().setText( ZONE_NAME, zoneName ); + } + + return l; +} + +QPoint +TimeZoneImageList::getLocationPosition( double longitude, double latitude ) +{ + constexpr double MAP_Y_OFFSET = 0.125; + constexpr double MAP_X_OFFSET = -0.0370; + constexpr double MATH_PI = 3.14159265; + + const int width = imageSize.width(); + const int height = imageSize.height(); + + double x = ( width / 2.0 + ( width / 2.0 ) * longitude / 180.0 ) + MAP_X_OFFSET * width; + double y = ( height / 2.0 - ( height / 2.0 ) * latitude / 90.0 ) + MAP_Y_OFFSET * height; + + // Far north, the MAP_Y_OFFSET no longer holds, cancel the Y offset; it's noticeable + // from 62 degrees north, so scale those 28 degrees as if the world is flat south + // of there, and we have a funny "rounded" top of the world. In practice the locations + // of the different cities / regions looks ok -- at least Thule ends up in the right + // country, and Inuvik isn't in the ocean. + if ( latitude > 70.0 ) + { + y -= sin( MATH_PI * ( latitude - 70.0 ) / 56.0 ) * MAP_Y_OFFSET * height * 0.8; + } + if ( latitude > 74.0 ) + { + y += 4; + } + if ( latitude > 69.0 ) + { + y -= 2; + } + if ( latitude > 59.0 ) + { + y -= 4 * int( ( latitude - 54.0 ) / 5.0 ); + } + if ( latitude > 54.0 ) + { + y -= 2; + } + if ( latitude > 49.0 ) + { + y -= int( ( latitude - 44.0 ) / 5.0 ); + } + // Far south, some stretching occurs as well, but it is less pronounced. + // Move down by 1 pixel per 5 degrees past 10 south + if ( latitude < 0 ) + { + y += int( ( -latitude ) / 5.0 ); + } + // Antarctica isn't shown on the map, but you could try clicking there + if ( latitude < -60 ) + { + y = height - 1; + } + + if ( x < 0 ) + { + x = width + x; + } + if ( x >= width ) + { + x -= width; + } + if ( y < 0 ) + { + y = height + y; + } + if ( y >= height ) + { + y -= height; + } + + return QPoint( int( x ), int( y ) ); +} + +// Pixel value indicating that a spot is outside of a zone +static constexpr const int RGB_TRANSPARENT = 0; + +int +TimeZoneImageList::index( QPoint pos, int& count ) const +{ + count = 0; + +#ifdef DEBUG_TIMEZONES + for ( int i = 0; i < size(); ++i ) + { + const QImage& zone = at( i ); + + // If not transparent set as current + if ( zone.pixel( pos ) != RGB_TRANSPARENT ) + { + // Log *all* the zones that contain this point, + // but only pick the first. + if ( !count ) + { + cDebug() << Logger::SubEntry << "First zone found" << i << zone.text( ZONE_NAME ); + } + else + { + cDebug() << Logger::SubEntry << "Also in zone" << i << zone.text( ZONE_NAME ); + } + count++; + } + } + if ( !count ) + { + return -1; + } +#endif + return index( pos ); +} + +int +TimeZoneImageList::index( QPoint pos ) const +{ + for ( int i = 0; i < size(); ++i ) + { + const QImage& zone = at( i ); + + // If not transparent set as current + if ( zone.pixel( pos ) != RGB_TRANSPARENT ) + { + return i; + } + } + return -1; +} + +QImage +TimeZoneImageList::find( QPoint p ) const +{ + int i = index( p ); + if ( i < 0 || size() <= i ) + { + return QImage(); + } + return at( i ); +} diff --git a/calamares/src/modules/locale/timezonewidget/TimeZoneImage.h b/calamares/src/modules/locale/timezonewidget/TimeZoneImage.h new file mode 100644 index 0000000..c36f4e2 --- /dev/null +++ b/calamares/src/modules/locale/timezonewidget/TimeZoneImage.h @@ -0,0 +1,76 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TIMEZONEIMAGE_H +#define TIMEZONEIMAGE_H + +#include +#include + +using TimeZoneImage = QImage; + +/** @brief All the timezone images + * + * There's one fixed list of timezone images that can be loaded + * from the QRC, or from the source directory. + */ +class TimeZoneImageList : public QList< TimeZoneImage > +{ +private: + TimeZoneImageList(); + +public: + /** @brief loads all the images from QRC. + * + * The images are assumed to be compiled into the Qt resource + * system and are loaded from there. + */ + static TimeZoneImageList fromQRC(); + /** @brief loads all the images from a specified directory. + * + * No error is returned if files are missing. + */ + static TimeZoneImageList fromDirectory( const QString& dirName ); + + /** @brief Map longitude and latitude to pixel positions + * + * The image is flat, and stretched at the poles and generally + * a bit weird, so this maps the global coordinates (as found in + * the zones table as a floating-point longitude and latitude value) + * to an x,y position. + */ + static QPoint getLocationPosition( double longitude, double latitude ); + + /** @brief Find the index of the image claiming point @p p + * + * This maps a point (presumably from getLocationPosition(), so + * originating from a longitude and latitude) to a specific zone + * image index. Returns -1 if no image claims the point (e.g. if + * it is out of bounds). + */ + int index( QPoint p ) const; + /** @brief Find the index of the image claiming point @p p + * + * As `index(p)`, but also fills in @p count with the number of + * zones that claim the point. + */ + int index( QPoint p, int& count ) const; + /** @brief Get image of the zone claiming @p p + * + * Can return a null image, if the point is unclaimed or invalid. + */ + QImage find( QPoint p ) const; + + /// @brief The **expected** number of zones in the list. + static constexpr const int zoneCount = 37; + /// @brief The expected size of each zone image. + static constexpr const QSize imageSize = QSize( 780, 340 ); +}; + +#endif diff --git a/calamares/src/modules/locale/timezonewidget/timezonewidget.cpp b/calamares/src/modules/locale/timezonewidget/timezonewidget.cpp new file mode 100644 index 0000000..67ecb09 --- /dev/null +++ b/calamares/src/modules/locale/timezonewidget/timezonewidget.cpp @@ -0,0 +1,197 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "locale/TimeZone.h" +#include "utils/Logger.h" + +#include "timezonewidget.h" + +#include +#include + +#ifdef DEBUG_TIMEZONES +// Adds a label to the timezone with this name +#define ZONE_NAME QStringLiteral( "zone" ) +#endif + +static QPoint +getLocationPosition( const Calamares::Locale::TimeZoneData* l ) +{ + return TimeZoneImageList::getLocationPosition( l->longitude(), l->latitude() ); +} + + +TimeZoneWidget::TimeZoneWidget( const Calamares::Locale::ZonesModel* zones, QWidget* parent ) + : QWidget( parent ) + , timeZoneImages( TimeZoneImageList::fromQRC() ) + , m_zonesData( zones ) +{ + setMouseTracking( false ); + setCursor( Qt::PointingHandCursor ); + + // Font + font.setPointSize( 12 ); + font.setBold( false ); + + // Images + background = QImage( ":/images/bg.png" ); + pin = QImage( ":/images/pin.png" ); + + // Set size + setMinimumSize( background.size() ); + setMaximumSize( background.size() ); + setSizePolicy( QSizePolicy::Fixed, QSizePolicy::Fixed ); +} + + +void +TimeZoneWidget::setCurrentLocation( const TimeZoneData* location ) +{ + if ( location == m_currentLocation ) + { + return; + } + + m_currentLocation = location; + + // Set zone + QPoint pos = getLocationPosition( location ); + +#ifdef DEBUG_TIMEZONES + cDebug() << "Setting location" << location->region() << location->zone() << '(' << location->country() << '@' + << location->latitude() << 'N' << location->longitude() << 'E' << ')'; + cDebug() << Logger::SubEntry << "pixel x" << pos.x() << "pixel y" << pos.y(); +#endif + + currentZoneImage = timeZoneImages.find( pos ); + + // Repaint widget + repaint(); +} + + +//### +//### Private +//### + +struct PainterEnder +{ + QPainter& p; + ~PainterEnder() { p.end(); } +}; + +void +TimeZoneWidget::paintEvent( QPaintEvent* ) +{ + QFontMetrics fontMetrics( font ); + QPainter painter( this ); + PainterEnder painter_end { painter }; + + painter.setRenderHint( QPainter::Antialiasing ); + painter.setFont( font ); + + // Draw background + painter.drawImage( 0, 0, background ); + + // Draw zone image + painter.drawImage( 0, 0, currentZoneImage ); + + if ( !m_currentLocation ) + { + return; + } + +#ifdef DEBUG_TIMEZONES + QPoint point = getLocationPosition( m_currentLocation ); + // Draw latitude lines + for ( int y_lat = -50; y_lat < 80; y_lat += 5 ) + { + QPen p( y_lat ? Qt::black : Qt::red ); + p.setWidth( 0 ); + painter.setPen( p ); + QPoint latLine0( TimeZoneImageList::getLocationPosition( 0, y_lat ) ); + painter.drawLine( 0, latLine0.y(), this->width() - 1, latLine0.y() ); + } + // Just a dot in the selected location, no label + painter.setPen( Qt::red ); + painter.drawPoint( point ); +#else + const int width = this->width(); + const int height = this->height(); + + // Draw pin at current location + QPoint point = getLocationPosition( m_currentLocation ); + + painter.drawImage( point.x() - pin.width() / 2, point.y() - pin.height() / 2, pin ); + + // Draw text and box + const int textWidth + = fontMetrics.horizontalAdvance( m_currentLocation ? m_currentLocation->translated() : QString() ); + const int textHeight = fontMetrics.height(); + + QRect rect = QRect( point.x() - textWidth / 2 - 5, point.y() - textHeight - 8, textWidth + 10, textHeight - 2 ); + + if ( rect.x() <= 5 ) + { + rect.moveLeft( 5 ); + } + if ( rect.right() >= width - 5 ) + { + rect.moveRight( width - 5 ); + } + if ( rect.y() <= 5 ) + { + rect.moveTop( 5 ); + } + if ( rect.y() >= height - 5 ) + { + rect.moveBottom( height - 5 ); + } + + painter.setPen( QPen() ); // no pen + painter.setBrush( QColor( 40, 40, 40 ) ); + painter.drawRoundedRect( rect, 3, 3 ); + painter.setPen( Qt::white ); + painter.drawText( + rect.x() + 5, rect.bottom() - 4, m_currentLocation ? m_currentLocation->translated() : QString() ); +#endif +} + + +void +TimeZoneWidget::mousePressEvent( QMouseEvent* event ) +{ + if ( event->button() != Qt::LeftButton ) + { + return; + } + + int mX = event->pos().x(); + int mY = event->pos().y(); + auto distance = [ & ]( const Calamares::Locale::TimeZoneData* zone ) + { + QPoint locPos = TimeZoneImageList::getLocationPosition( zone->longitude(), zone->latitude() ); + return double( abs( mX - locPos.x() ) + abs( mY - locPos.y() ) ); + }; + + const auto* closest = m_zonesData->find( distance ); + if ( closest ) + { + // Set zone image and repaint widget + setCurrentLocation( closest ); + // Emit signal + emit locationChanged( closest ); + } +} diff --git a/calamares/src/modules/locale/timezonewidget/timezonewidget.h b/calamares/src/modules/locale/timezonewidget/timezonewidget.h new file mode 100644 index 0000000..f40bb54 --- /dev/null +++ b/calamares/src/modules/locale/timezonewidget/timezonewidget.h @@ -0,0 +1,73 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Originally from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TIMEZONEWIDGET_H +#define TIMEZONEWIDGET_H + +#include "TimeZoneImage.h" + +#include "locale/TimeZone.h" + +#include +#include + +/** @brief The TimeZoneWidget shows a map and reports where clicks happen + * + * This widget shows a map (unspecified whether it's a whole world map + * or can show regionsvia some kind of internal state). Mouse clicks are + * translated into timezone locations (e.g. the zone for America/New_York). + * + * The current location can be changed programmatically, by name + * or through a pointer to a location. If a pointer is used, take care + * that the pointer is to a zone in the same model as used by the + * widget. + * + * When a location is chosen -- by mouse click or programmatically -- + * the locationChanged() signal is emitted with the new location. + * + * NOTE: the widget currently uses the globally cached TZRegion::fromZoneTab() + */ +class TimeZoneWidget : public QWidget +{ + Q_OBJECT +public: + using TimeZoneData = Calamares::Locale::TimeZoneData; + + explicit TimeZoneWidget( const Calamares::Locale::ZonesModel* zones, QWidget* parent = nullptr ); + +public Q_SLOTS: + /** @brief Sets a location by pointer + * + * Pointer should be within the same model as the widget uses. + */ + void setCurrentLocation( const TimeZoneData* location ); + +signals: + /** @brief The location has changed by mouse click */ + void locationChanged( const TimeZoneData* location ); + +private: + QFont font; + QImage background, pin, currentZoneImage; + TimeZoneImageList timeZoneImages; + + const Calamares::Locale::ZonesModel* m_zonesData; + const TimeZoneData* m_currentLocation = nullptr; // Not owned by me + + void paintEvent( QPaintEvent* event ) override; + void mousePressEvent( QMouseEvent* event ) override; +}; + +#endif // TIMEZONEWIDGET_H diff --git a/calamares/src/modules/localecfg/main.py b/calamares/src/modules/localecfg/main.py new file mode 100644 index 0000000..9cf5930 --- /dev/null +++ b/calamares/src/modules/localecfg/main.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Anke Boersma +# SPDX-FileCopyrightText: 2015 Philip Müller +# SPDX-FileCopyrightText: 2016 Teo Mrnjavac +# SPDX-FileCopyrightText: 2018 AlmAck +# SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import os +import re +import shutil + + +import libcalamares + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Configuring locales.") + + +RE_IS_COMMENT = re.compile("^ *#") +def is_comment(line): + """ + Does the @p line look like a comment? Whitespace, followed by a # + is a comment-only line. + """ + return bool(RE_IS_COMMENT.match(line)) + +RE_TRAILING_COMMENT = re.compile("#.*$") +RE_REST_OF_LINE = re.compile("\\s.*$") +def extract_locale(line): + """ + Extracts a locale from the @p line, and returns a pair of + (extracted-locale, uncommented line). The locale is the + first word of the line after uncommenting (in the human- + readable text explanation at the top of most /etc/locale.gen + files, the locales may be bogus -- either "" or e.g. "Configuration") + """ + # Remove leading spaces and comment signs + line = RE_IS_COMMENT.sub("", line) + uncommented = line.strip() + fields = RE_TRAILING_COMMENT.sub("", uncommented).strip().split() + if len(fields) != 2: + # Not exactly two fields, can't be a proper locale line + return "", uncommented + else: + # Drop all but first field + locale = RE_REST_OF_LINE.sub("", uncommented) + return locale, uncommented + + +def rewrite_locale_gen(srcfilename, destfilename, locale_conf): + """ + Copies a locale.gen file from @p srcfilename to @p destfilename + (this may be the same name), enabling those locales that can + be found in the map @p locale_conf. Also always enables en_US.UTF-8. + """ + en_us_locale = 'en_US.UTF-8' + + # Get entire source-file contents + text = [] + try: + with open(srcfilename, "r") as gen: + text = gen.readlines() + except FileNotFoundError: + # That's ok, the file doesn't exist so assume empty + pass + + # we want unique values, so locale_values should have 1 or 2 items + locale_values = set(locale_conf.values()) + locale_values.add(en_us_locale) # Always enable en_US as well + + enabled_locales = {} + seen_locales = set() + + # Write source out again, enabling some + with open(destfilename, "w") as gen: + for line in text: + c = is_comment(line) + locale, uncommented = extract_locale(line) + + # Non-comment lines are preserved, and comment lines + # may be enabled if they match a desired locale + if not c: + seen_locales.add(locale) + else: + for locale_value in locale_values: + if locale.startswith(locale_value): + enabled_locales[locale] = uncommented + gen.write(line) + + gen.write("\n###\n#\n# Locales enabled by Calamares\n") + for locale, line in enabled_locales.items(): + if locale not in seen_locales: + gen.write(line + "\n") + seen_locales.add(locale) + + for locale in locale_values: + if locale not in seen_locales: + gen.write("# Missing: %s\n" % locale) + + +def run(): + """ Create locale """ + import libcalamares + + locale_conf = libcalamares.globalstorage.value("localeConf") + + if not locale_conf: + locale_conf = { + 'LANG': 'en_US.UTF-8', + 'LC_NUMERIC': 'en_US.UTF-8', + 'LC_TIME': 'en_US.UTF-8', + 'LC_MONETARY': 'en_US.UTF-8', + 'LC_PAPER': 'en_US.UTF-8', + 'LC_NAME': 'en_US.UTF-8', + 'LC_ADDRESS': 'en_US.UTF-8', + 'LC_TELEPHONE': 'en_US.UTF-8', + 'LC_MEASUREMENT': 'en_US.UTF-8', + 'LC_IDENTIFICATION': 'en_US.UTF-8' + } + + install_path = libcalamares.globalstorage.value("rootMountPoint") + + if install_path is None: + libcalamares.utils.warning("rootMountPoint is empty, {!s}".format(install_path)) + return (_("Configuration Error"), + _("No root mount point is given for

{!s}
to use." ).format("localecfg")) + + target_locale_gen = "{!s}/etc/locale.gen".format(install_path) + target_locale_gen_bak = target_locale_gen + ".bak" + target_locale_conf_path = "{!s}/etc/locale.conf".format(install_path) + target_etc_default_path = "{!s}/etc/default".format(install_path) + + # restore backup if available + if os.path.exists(target_locale_gen_bak): + shutil.copy2(target_locale_gen_bak, target_locale_gen) + libcalamares.utils.debug("Restored backup {!s} -> {!s}" + .format(target_locale_gen_bak, target_locale_gen)) + + # run locale-gen if detected; this *will* cause an exception + # if the live system has locale.gen, but the target does not: + # in that case, fix your installation filesystem. + if os.path.exists('/etc/locale.gen'): + rewrite_locale_gen(target_locale_gen, target_locale_gen, locale_conf) + libcalamares.utils.target_env_call(['locale-gen']) + libcalamares.utils.debug('{!s} done'.format(target_locale_gen)) + + # write /etc/locale.conf + with open(target_locale_conf_path, "w") as lcf: + for k, v in locale_conf.items(): + lcf.write("{!s}={!s}\n".format(k, v)) + libcalamares.utils.debug('{!s} done'.format(target_locale_conf_path)) + + # write /etc/default/locale if /etc/default exists and is a dir + if os.path.isdir(target_etc_default_path): + with open(os.path.join(target_etc_default_path, "locale"), "w") as edl: + for k, v in locale_conf.items(): + edl.write("{!s}={!s}\n".format(k, v)) + libcalamares.utils.debug('{!s} done'.format(target_etc_default_path)) + + return None diff --git a/calamares/src/modules/localecfg/module.desc b/calamares/src/modules/localecfg/module.desc new file mode 100644 index 0000000..030846a --- /dev/null +++ b/calamares/src/modules/localecfg/module.desc @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# Enable the configured locales (those set by the user on the +# user page) in /etc/locale.gen, if they are available in the +# target system. +--- +type: "job" +name: "localecfg" +interface: "python" +script: "main.py" +noconfig: true diff --git a/calamares/src/modules/localeq/CMakeLists.txt b/calamares/src/modules/localeq/CMakeLists.txt new file mode 100644 index 0000000..eb7cbf0 --- /dev/null +++ b/calamares/src/modules/localeq/CMakeLists.txt @@ -0,0 +1,42 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +if(NOT WITH_QML) + calamares_skip_module( "localeq (QML is not supported in this build)" ) + return() +endif() + +# When debugging the timezone widget, add this debugging definition +# to have a debugging-friendly timezone widget, debug logging, +# and no intrusive timezone-setting while clicking around. +option(DEBUG_TIMEZONES "Debug-friendly timezone widget." OFF) + +find_package(${qtname}Location CONFIG) +set_package_properties(${qtname}Location PROPERTIES DESCRIPTION "Used for rendering the map" TYPE RUNTIME) +find_package(${qtname}Positioning CONFIG) +set_package_properties(${qtname}Positioning PROPERTIES DESCRIPTION "Used for GeoLocation and GeoCoding" TYPE RUNTIME) + +# Because we're sharing sources with the regular locale module +set(_locale ${CMAKE_CURRENT_SOURCE_DIR}/../locale) + +calamares_add_plugin(localeq + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + LocaleQmlViewStep.cpp + ${_locale}/Config.cpp + ${_locale}/LocaleConfiguration.cpp + ${_locale}/LocaleNames.cpp + ${_locale}/SetTimezoneJob.cpp + RESOURCES + localeq${QT_VERSION_SUFFIX}.qrc + LINK_PRIVATE_LIBRARIES + ${qtname}::Network + SHARED_LIB +) +target_include_directories(${localeq_TARGET} PRIVATE ${_locale}) +if(DEBUG_TIMEZONES) + target_compile_definitions(${localeq_TARGET} PRIVATE DEBUG_TIMEZONES) +endif() diff --git a/calamares/src/modules/localeq/LocaleQmlViewStep.cpp b/calamares/src/modules/localeq/LocaleQmlViewStep.cpp new file mode 100644 index 0000000..37f131c --- /dev/null +++ b/calamares/src/modules/localeq/LocaleQmlViewStep.cpp @@ -0,0 +1,97 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 20182020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LocaleQmlViewStep.h" + +#include "utils/Logger.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( LocaleQmlViewStepFactory, registerPlugin< LocaleQmlViewStep >(); ) + +LocaleQmlViewStep::LocaleQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) + , m_config( std::make_unique< Config >( this ) ) +{ +} + +QObject* +LocaleQmlViewStep::getConfig() +{ + return m_config.get(); +} + +QString +LocaleQmlViewStep::prettyName() const +{ + return tr( "Location", "@label" ); +} + +QString +LocaleQmlViewStep::prettyStatus() const +{ + return m_config->prettyStatus(); +} + +bool +LocaleQmlViewStep::isNextEnabled() const +{ + return true; +} + +bool +LocaleQmlViewStep::isBackEnabled() const +{ + return true; +} + + +bool +LocaleQmlViewStep::isAtBeginning() const +{ + return true; +} + + +bool +LocaleQmlViewStep::isAtEnd() const +{ + return true; +} + +Calamares::JobList +LocaleQmlViewStep::jobs() const +{ + return m_config->createJobs(); +} + +void +LocaleQmlViewStep::onActivate() +{ + m_config->setCurrentLocation(); // Finalize the location + QmlViewStep::onActivate(); +} + +void +LocaleQmlViewStep::onLeave() +{ + m_config->finalizeGlobalStorage(); +} + +void +LocaleQmlViewStep::onCancel() +{ + m_config->cancel(); +} + +void +LocaleQmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); + QmlViewStep::setConfigurationMap( configurationMap ); // call parent implementation last +} diff --git a/calamares/src/modules/localeq/LocaleQmlViewStep.h b/calamares/src/modules/localeq/LocaleQmlViewStep.h new file mode 100644 index 0000000..a7b20ba --- /dev/null +++ b/calamares/src/modules/localeq/LocaleQmlViewStep.h @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LOCALE_QMLVIEWSTEP_H +#define LOCALE_QMLVIEWSTEP_H + +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/QmlViewStep.h" + +#include + +class PLUGINDLLEXPORT LocaleQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + explicit LocaleQmlViewStep( QObject* parent = nullptr ); + + QString prettyName() const override; + QString prettyStatus() const override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onActivate() override; + void onLeave() override; + void onCancel() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + QObject* getConfig() override; + +private: + std::unique_ptr< Config > m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( LocaleQmlViewStepFactory ) + +#endif diff --git a/calamares/src/modules/localeq/Map-qt6.qml b/calamares/src/modules/localeq/Map-qt6.qml new file mode 100644 index 0000000..7c8473e --- /dev/null +++ b/calamares/src/modules/localeq/Map-qt6.qml @@ -0,0 +1,287 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2024 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +import QtLocation +import QtPositioning + +Column { + width: parent.width + + // These are used by the map query to initially center the + // map on the user's likely location. They are updated by + // getIp() which does a more accurate GeoIP lookup than + // the default one in Calamares + property var cityName: "" + property var countryName: "" + + /* This is an extra GeoIP lookup, which will find better-accuracy + * location data for the user's IP, and then sets the current timezone + * and map location. Call it from Component.onCompleted so that + * it happens "on time" before the page is shown. + */ + function getIpOnline() { + var xhr = new XMLHttpRequest + + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + var responseJSON = JSON.parse(xhr.responseText) + var tz = responseJSON.timezone + var ct = responseJSON.city + var cy = responseJSON.country + + cityName = ct + countryName = cy + + config.setCurrentLocation(tz) + } + } + + // Define the target of the request + xhr.open("GET", "https://get.geojs.io/v1/ip/geo.json") + // Execute the request + xhr.send() + } + + /* This is an "offline" GeoIP lookup -- it just follows what + * Calamares itself has figured out with its GeoIP or configuration. + * Call it from the **Component** onActivate() -- in localeq.qml -- + * so it happens as the page is shown. + */ + function getIpOffline() { + cityName = config.currentLocation.zone + countryName = config.currentLocation.countryCode + } + + /* This is an **accurate** TZ lookup method: it queries an + * online service for the TZ at the given coordinates. It + * requires an internet connection, though, and the distribution + * will need to have an account with geonames to not hit the + * daily query limit. + * + * See below, in MouseArea, for calling the right method. + */ + function getTzOnline() { + var xhr = new XMLHttpRequest + var latC = map.center.latitude + var lonC = map.center.longitude + + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + var responseJSON = JSON.parse(xhr.responseText) + var tz2 = responseJSON.timezoneId + + config.setCurrentLocation(tz2) + } + } + + console.log("Online lookup", latC, lonC) + // Needs to move to localeq.conf, each distribution will need their own account + xhr.open("GET", "http://api.geonames.org/timezoneJSON?lat=" + latC + "&lng=" + lonC + "&username=SOME_USERNAME") + xhr.send() + } + + /* This is a quick TZ lookup method: it uses the existing + * Calamares "closest TZ" code, which has lots of caveats. + * + * See below, in MouseArea, for calling the right method. + */ + function getTzOffline() { + var latC = map.center.latitude + var lonC = map.center.longitude + var tz = config.zonesModel.lookup(latC, lonC) + console.log("Offline lookup", latC, lonC) + config.setCurrentLocation(tz.region, tz.zone) + } + + Rectangle { + width: parent.width + height: parent.height / 1.28 + + Plugin { + id: mapPlugin + name: ["osm"] + } + + Map { + id: map + anchors.fill: parent + plugin: mapPlugin + activeMapType: supportedMapTypes[0] + //might be desirable to set zoom level configurable? + zoomLevel: 7 + bearing: 0 + tilt: 0 + copyrightsVisible : true + fieldOfView : 0 + + GeocodeModel { + id: geocodeModel + plugin: mapPlugin + autoUpdate: true + query: Address { + id: address + city: cityName + country: countryName + } + + onLocationsChanged: { + if (count == 1) { + map.center.latitude = get(0).coordinate.latitude + map.center.longitude = get(0).coordinate.longitude + } + } + } + + MapQuickItem { + id: marker + anchorPoint.x: image.width/4 + anchorPoint.y: image.height + coordinate: QtPositioning.coordinate( + map.center.latitude, + map.center.longitude) + //coordinate: QtPositioning.coordinate(40.730610, -73.935242) // New York + + sourceItem: Image { + id: image + width: 32 + height: 32 + source: "img/pin.svg" + } + } + + MouseArea { + acceptedButtons: Qt.LeftButton + anchors.fill: map + hoverEnabled: true + property var coordinate: map.toCoordinate(Qt.point(mouseX, mouseY)) + + onClicked: { + marker.coordinate = coordinate + map.center.latitude = coordinate.latitude + map.center.longitude = coordinate.longitude + + // Pick a TZ lookup method here (quick:offline, accurate:online) + getTzOffline(); + } + } + + WheelHandler { + id: wheel + acceptedDevices: Qt.platform.pluginName === "cocoa" || Qt.platform.pluginName === "wayland" + ? PointerDevice.Mouse | PointerDevice.TouchPad + : PointerDevice.Mouse + rotationScale: 1/120 + property: "zoomLevel" + } + DragHandler { + id: drag + target: null + onTranslationChanged: (delta) => map.pan(-delta.x, -delta.y) + } + Shortcut { + enabled: map.zoomLevel < map.maximumZoomLevel + sequence: StandardKey.ZoomIn + onActivated: map.zoomLevel = Math.round(map.zoomLevel + 1) + } + Shortcut { + enabled: map.zoomLevel > map.minimumZoomLevel + sequence: StandardKey.ZoomOut + onActivated: map.zoomLevel = Math.round(map.zoomLevel - 1) + } + } + + Column { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: 5 + anchors.rightMargin: 10 + + MouseArea { + width: 32 + height:32 + cursorShape: Qt.PointingHandCursor + Image { + source: "img/plus.png" + anchors.centerIn: parent + width: 36 + height: 36 + } + + onClicked: map.zoomLevel++ + } + + MouseArea { + width: 32 + height:32 + cursorShape: Qt.PointingHandCursor + Image { + source: "img/minus.png" + anchors.centerIn: parent + width: 32 + height: 32 + } + + onClicked: map.zoomLevel-- + } + } + } + + Rectangle { + width: parent.width + height: 100 + anchors.horizontalCenter: parent.horizontalCenter + + Item { + id: location + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Complementary + anchors.horizontalCenter: parent.horizontalCenter + + Rectangle { + anchors.centerIn: parent + width: 300 + height: 30 + color: Kirigami.Theme.backgroundColor + + Text { + id: tzText + text: qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + color: Kirigami.Theme.textColor + anchors.centerIn: parent + } + + /* If you want an extra (and accurate) GeoIP lookup, + * enable this one and disable the offline lookup in + * onActivate(). + Component.onCompleted: getIpOnline(); + */ + } + } + + Text { + anchors.top: location.bottom + anchors.topMargin: 20 + padding: 10 + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Kirigami.Theme.backgroundColor: Kirigami.Theme.backgroundColor + text: qsTr("Please select your preferred location on the map so the installer can suggest the locale + and timezone settings for you. You can fine-tune the suggested settings below. Search the map by dragging + to move and using the +/- buttons to zoom in/out or use mouse scrolling for zooming.", "@label") + } + } +} diff --git a/calamares/src/modules/localeq/Map.qml b/calamares/src/modules/localeq/Map.qml new file mode 100644 index 0000000..d6b55d5 --- /dev/null +++ b/calamares/src/modules/localeq/Map.qml @@ -0,0 +1,263 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2022 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import QtQuick 2.10 +import QtQuick.Controls 2.10 +import QtQuick.Window 2.14 +import QtQuick.Layouts 1.3 + +import org.kde.kirigami 2.7 as Kirigami + +import QtLocation 5.14 +import QtPositioning 5.14 + +Column { + width: parent.width + + // These are used by the map query to initially center the + // map on the user's likely location. They are updated by + // getIp() which does a more accurate GeoIP lookup than + // the default one in Calamares + property var cityName: "" + property var countryName: "" + + /* This is an extra GeoIP lookup, which will find better-accuracy + * location data for the user's IP, and then sets the current timezone + * and map location. Call it from Component.onCompleted so that + * it happens "on time" before the page is shown. + */ + function getIpOnline() { + var xhr = new XMLHttpRequest + + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + var responseJSON = JSON.parse(xhr.responseText) + var tz = responseJSON.timezone + var ct = responseJSON.city + var cy = responseJSON.country + + cityName = ct + countryName = cy + + config.setCurrentLocation(tz) + } + } + + // Define the target of the request + xhr.open("GET", "https://get.geojs.io/v1/ip/geo.json") + // Execute the request + xhr.send() + } + + /* This is an "offline" GeoIP lookup -- it just follows what + * Calamares itself has figured out with its GeoIP or configuration. + * Call it from the **Component** onActivate() -- in localeq.qml -- + * so it happens as the page is shown. + */ + function getIpOffline() { + cityName = config.currentLocation.zone + countryName = config.currentLocation.countryCode + } + + /* This is an **accurate** TZ lookup method: it queries an + * online service for the TZ at the given coordinates. It + * requires an internet connection, though, and the distribution + * will need to have an account with geonames to not hit the + * daily query limit. + * + * See below, in MouseArea, for calling the right method. + */ + function getTzOnline() { + var xhr = new XMLHttpRequest + var latC = map.center.latitude + var lonC = map.center.longitude + + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + var responseJSON = JSON.parse(xhr.responseText) + var tz2 = responseJSON.timezoneId + + config.setCurrentLocation(tz2) + } + } + + console.log("Online lookup", latC, lonC) + // Needs to move to localeq.conf, each distribution will need their own account + xhr.open("GET", "http://api.geonames.org/timezoneJSON?lat=" + latC + "&lng=" + lonC + "&username=SOME_USERNAME") + xhr.send() + } + + /* This is a quick TZ lookup method: it uses the existing + * Calamares "closest TZ" code, which has lots of caveats. + * + * See below, in MouseArea, for calling the right method. + */ + function getTzOffline() { + var latC = map.center.latitude + var lonC = map.center.longitude + var tz = config.zonesModel.lookup(latC, lonC) + console.log("Offline lookup", latC, lonC) + config.setCurrentLocation(tz.region, tz.zone) + } + + Rectangle { + width: parent.width + height: parent.height / 1.28 + + Plugin { + id: mapPlugin + preferred: ["osm", "esri"] // "esri", "here", "itemsoverlay", "mapbox", "mapboxgl", "osm" + } + + Map { + id: map + anchors.fill: parent + plugin: mapPlugin + activeMapType: supportedMapTypes[0] + //might be desirable to set zoom level configurable? + zoomLevel: 7 + bearing: 0 + tilt: 0 + copyrightsVisible : true + fieldOfView : 0 + + GeocodeModel { + id: geocodeModel + plugin: mapPlugin + autoUpdate: true + query: Address { + id: address + city: cityName + country: countryName + } + + onLocationsChanged: { + if (count == 1) { + map.center.latitude = get(0).coordinate.latitude + map.center.longitude = get(0).coordinate.longitude + } + } + } + + MapQuickItem { + id: marker + anchorPoint.x: image.width/4 + anchorPoint.y: image.height + coordinate: QtPositioning.coordinate( + map.center.latitude, + map.center.longitude) + //coordinate: QtPositioning.coordinate(40.730610, -73.935242) // New York + + sourceItem: Image { + id: image + width: 32 + height: 32 + source: "img/pin.svg" + } + } + + MouseArea { + acceptedButtons: Qt.LeftButton + anchors.fill: map + hoverEnabled: true + property var coordinate: map.toCoordinate(Qt.point(mouseX, mouseY)) + + onClicked: { + marker.coordinate = coordinate + map.center.latitude = coordinate.latitude + map.center.longitude = coordinate.longitude + + // Pick a TZ lookup method here (quick:offline, accurate:online) + getTzOffline(); + } + } + } + + Column { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: 5 + anchors.rightMargin: 10 + + MouseArea { + width: 32 + height:32 + cursorShape: Qt.PointingHandCursor + Image { + source: "img/plus.png" + anchors.centerIn: parent + width: 36 + height: 36 + } + + onClicked: map.zoomLevel++ + } + + MouseArea { + width: 32 + height:32 + cursorShape: Qt.PointingHandCursor + Image { + source: "img/minus.png" + anchors.centerIn: parent + width: 32 + height: 32 + } + + onClicked: map.zoomLevel-- + } + } + } + + Rectangle { + width: parent.width + height: 100 + anchors.horizontalCenter: parent.horizontalCenter + + Item { + id: location + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Complementary + anchors.horizontalCenter: parent.horizontalCenter + + Rectangle { + anchors.centerIn: parent + width: 300 + height: 30 + color: Kirigami.Theme.backgroundColor + + Text { + id: tzText + text: qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + color: Kirigami.Theme.textColor + anchors.centerIn: parent + } + + /* If you want an extra (and accurate) GeoIP lookup, + * enable this one and disable the offline lookup in + * onActivate(). + Component.onCompleted: getIpOnline(); + */ + } + } + + Text { + anchors.top: location.bottom + anchors.topMargin: 20 + padding: 10 + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Kirigami.Theme.backgroundColor: Kirigami.Theme.backgroundColor + text: qsTr("Please select your preferred location on the map so the installer can suggest the locale + and timezone settings for you. You can fine-tune the suggested settings below. Search the map by dragging + to move and using the +/- buttons to zoom in/out or use mouse scrolling for zooming.", "@info") + } + } +} diff --git a/calamares/src/modules/localeq/Offline-qt6.qml b/calamares/src/modules/localeq/Offline-qt6.qml new file mode 100644 index 0000000..7d319a1 --- /dev/null +++ b/calamares/src/modules/localeq/Offline-qt6.qml @@ -0,0 +1,243 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020-2022 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +Page { + width: 800 //parent.width + height: 500 + + id: control + property string currentRegion + property string currentZone + + readonly property color backgroundColor: Kirigami.Theme.backgroundColor //"#F5F5F5" + readonly property color backgroundLighterColor: "#ffffff" + readonly property color highlightColor: Kirigami.Theme.highlightColor //"#3498DB" + readonly property color textColor: Kirigami.Theme.textColor + readonly property color highlightedTextColor: Kirigami.Theme.highlightedTextColor + + StackView { + id: stack + anchors.fill: parent + clip: true + + initialItem: Item { + + Label { + + id: region + anchors.horizontalCenter: parent.horizontalCenter + color: textColor + horizontalAlignment: Text.AlignCenter + text: qsTr("Select your preferred region, or use the default settings", "@label") + } + + ListView { + + id: list + ScrollBar.vertical: ScrollBar { + active: true + } + + width: parent.width / 2 + height: parent.height / 1.5 + anchors.centerIn: parent + anchors.verticalCenterOffset: -30 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + + Rectangle { + + z: parent.z - 1 + anchors.fill: parent + color: backgroundLighterColor + } + + model: config.regionModel + currentIndex: 1 // offline install, means locale from config + delegate: ItemDelegate { + + hoverEnabled: true + width: parent.width + height: 28 + highlighted: ListView.isCurrentItem + + Label { + + text: model.name + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: parent.width + height: 28 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 1 + } + } + + onClicked: { + + list.currentIndex = index + control.currentRegion = model.name + config.regionalZonesModel.region = control.currentRegion + tztext.text = qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + stack.push(zoneView) + } + } + } + } + + Component { + id: zoneView + + Item { + + Label { + + id: zone + anchors.horizontalCenter: parent.horizontalCenter + color: textColor + text: qsTr("Select your preferred zone within your region", "@label") + } + + ListView { + + id: list2 + ScrollBar.vertical: ScrollBar { + active: true + } + + width: parent.width / 2 + height: parent.height / 1.5 + anchors.centerIn: parent + anchors.verticalCenterOffset: -30 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + + Rectangle { + + z: parent.z - 1 + anchors.fill: parent + color: backgroundLighterColor + //radius: 5 + //opacity: 0.7 + } + + model: config.regionalZonesModel + currentIndex : 99 // index of New York + Component.onCompleted: positionViewAtIndex(currentIndex, ListView.Center) + delegate: ItemDelegate { + + hoverEnabled: true + width: parent.width + height: 24 + highlighted: ListView.isCurrentItem + + Label { + + text: model.name + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: parent.width + height: 24 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 1 + } + } + + onClicked: { + + list2.currentIndex = index + list2.positionViewAtIndex(index, ListView.Center) + control.currentZone = model.name + config.setCurrentLocation(control.currentRegion, control.currentZone) + tztext.text = qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + } + } + } + + Button { + + Layout.fillWidth: true + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -30 + anchors.left: parent.left + anchors.leftMargin: parent.width / 15 + icon.name: "go-previous" + text: qsTr("Zones", "@button") + onClicked: stack.pop() + } + } + } + } + + Rectangle { + + width: parent.width + height: 60 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + Item { + + id: location + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Complementary + anchors.horizontalCenter: parent.horizontalCenter + + Rectangle { + + anchors.centerIn: parent + width: 300 + height: 30 + color: Kirigami.Theme.backgroundColor + + Text { + + id: tztext + text: qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + color: Kirigami.Theme.textColor + anchors.centerIn: parent + } + } + } + + Text { + + anchors.top: location.bottom + anchors.topMargin: 20 + padding: 10 + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Kirigami.Theme.backgroundColor: Kirigami.Theme.backgroundColor + text: qsTr("You can fine-tune language and locale settings below", "@label") + } + } +} diff --git a/calamares/src/modules/localeq/Offline.qml b/calamares/src/modules/localeq/Offline.qml new file mode 100644 index 0000000..9a4aef0 --- /dev/null +++ b/calamares/src/modules/localeq/Offline.qml @@ -0,0 +1,243 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020-2022 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.10 +import QtQuick.Controls 2.10 +import QtQuick.Window 2.14 +import QtQuick.Layouts 1.3 + +import org.kde.kirigami 2.7 as Kirigami + +Page { + width: 800 //parent.width + height: 500 + + id: control + property string currentRegion + property string currentZone + + readonly property color backgroundColor: Kirigami.Theme.backgroundColor //"#F5F5F5" + readonly property color backgroundLighterColor: "#ffffff" + readonly property color highlightColor: Kirigami.Theme.highlightColor //"#3498DB" + readonly property color textColor: Kirigami.Theme.textColor + readonly property color highlightedTextColor: Kirigami.Theme.highlightedTextColor + + StackView { + id: stack + anchors.fill: parent + clip: true + + initialItem: Item { + + Label { + + id: region + anchors.horizontalCenter: parent.horizontalCenter + color: textColor + horizontalAlignment: Text.AlignCenter + text: qsTr("Select your preferred region, or use the default settings", "@label") + } + + ListView { + + id: list + ScrollBar.vertical: ScrollBar { + active: true + } + + width: parent.width / 2 + height: parent.height / 1.5 + anchors.centerIn: parent + anchors.verticalCenterOffset: -30 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + + Rectangle { + + z: parent.z - 1 + anchors.fill: parent + color: backgroundLighterColor + } + + model: config.regionModel + currentIndex: 1 // offline install, means locale from config + delegate: ItemDelegate { + + hoverEnabled: true + width: parent.width + height: 28 + highlighted: ListView.isCurrentItem + + Label { + + text: model.name + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: parent.width + height: 28 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 1 + } + } + + onClicked: { + + list.currentIndex = index + control.currentRegion = model.name + config.regionalZonesModel.region = control.currentRegion + tztext.text = qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + stack.push(zoneView) + } + } + } + } + + Component { + id: zoneView + + Item { + + Label { + + id: zone + anchors.horizontalCenter: parent.horizontalCenter + color: textColor + text: qsTr("Select your preferred zone within your region", "@label") + } + + ListView { + + id: list2 + ScrollBar.vertical: ScrollBar { + active: true + } + + width: parent.width / 2 + height: parent.height / 1.5 + anchors.centerIn: parent + anchors.verticalCenterOffset: -30 + focus: true + clip: true + boundsBehavior: Flickable.StopAtBounds + spacing: 2 + + Rectangle { + + z: parent.z - 1 + anchors.fill: parent + color: backgroundLighterColor + //radius: 5 + //opacity: 0.7 + } + + model: config.regionalZonesModel + currentIndex : 99 // index of New York + Component.onCompleted: positionViewAtIndex(currentIndex, ListView.Center) + delegate: ItemDelegate { + + hoverEnabled: true + width: parent.width + height: 24 + highlighted: ListView.isCurrentItem + + Label { + + text: model.name + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + width: parent.width + height: 24 + color: highlighted ? highlightedTextColor : textColor + + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 1 + } + } + + onClicked: { + + list2.currentIndex = index + list2.positionViewAtIndex(index, ListView.Center) + control.currentZone = model.name + config.setCurrentLocation(control.currentRegion, control.currentZone) + tztext.text = qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + } + } + } + + Button { + + Layout.fillWidth: true + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -30 + anchors.left: parent.left + anchors.leftMargin: parent.width / 15 + icon.name: "go-previous" + text: qsTr("Zones", "@button") + onClicked: stack.pop() + } + } + } + } + + Rectangle { + + width: parent.width + height: 60 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + Item { + + id: location + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.Complementary + anchors.horizontalCenter: parent.horizontalCenter + + Rectangle { + + anchors.centerIn: parent + width: 300 + height: 30 + color: Kirigami.Theme.backgroundColor + + Text { + + id: tztext + text: qsTr("Timezone: %1", "@label").arg(config.currentTimezoneName) + color: Kirigami.Theme.textColor + anchors.centerIn: parent + } + } + } + + Text { + + anchors.top: location.bottom + anchors.topMargin: 20 + padding: 10 + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + Kirigami.Theme.backgroundColor: Kirigami.Theme.backgroundColor + text: qsTr("You can fine-tune language and locale settings below", "@label") + } + } +} diff --git a/calamares/src/modules/localeq/img/locale.svg b/calamares/src/modules/localeq/img/locale.svg new file mode 100755 index 0000000..20c21e5 --- /dev/null +++ b/calamares/src/modules/localeq/img/locale.svg @@ -0,0 +1,5720 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/localeq/img/locale.svg.license b/calamares/src/modules/localeq/img/locale.svg.license new file mode 100644 index 0000000..5f43e65 --- /dev/null +++ b/calamares/src/modules/localeq/img/locale.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2020 demmm +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/localeq/img/minus.png b/calamares/src/modules/localeq/img/minus.png new file mode 100644 index 0000000000000000000000000000000000000000..be122dff24d8f51868e75d1249794d9073fcc1e4 GIT binary patch literal 177 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9F5M?jcysy3fAP%zok z#WAE}&fAL@IU5WFSPy3Ww>>V_BO#Xh?Eiy8YmjbaP;>I*gS3779>q_qt9lo8(3a^4 tO9!Wbu!5R{Ps0obL&hYa0J3@zTP#L;AEU&El#Jsbo~Nsy%Q~loCICDuEh+#2 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/localeq/img/minus.png.license b/calamares/src/modules/localeq/img/minus.png.license new file mode 100644 index 0000000..5f43e65 --- /dev/null +++ b/calamares/src/modules/localeq/img/minus.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2020 demmm +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/localeq/img/pin.svg b/calamares/src/modules/localeq/img/pin.svg new file mode 100644 index 0000000..b4185d1 --- /dev/null +++ b/calamares/src/modules/localeq/img/pin.svg @@ -0,0 +1,60 @@ + +image/svg+xml + + \ No newline at end of file diff --git a/calamares/src/modules/localeq/img/pin.svg.license b/calamares/src/modules/localeq/img/pin.svg.license new file mode 100644 index 0000000..5f43e65 --- /dev/null +++ b/calamares/src/modules/localeq/img/pin.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2020 demmm +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/localeq/img/plus.png b/calamares/src/modules/localeq/img/plus.png new file mode 100644 index 0000000000000000000000000000000000000000..3bd5d832c21a5dddebf6c0d2a3df7281259dc388 GIT binary patch literal 483 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcgSkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+ueoXe|!I#{XiaP zfk$L90|U1(2s1Lwnj--eWH0gbb!C6TBF1md5Vd00KA_MwPZ!6Kid%1Q8FC$R5OBEI z8KL*0aZl6ov!X@wHJ0@E9zCY^I^&1VpLErh!nz3}s|6>icuu;q^5KS)$x~k46)Ly- z8eP2RYPQ|0WWSQ-j9-}8E^tdY=qE7jZNSMnus*6!?D!5?)FK#IZ0z{o(? zz(m)`D8$gf%Fxuxz+BtFz{ +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/localeq/localeq-qt6.qml b/calamares/src/modules/localeq/localeq-qt6.qml new file mode 100644 index 0000000..956f07d --- /dev/null +++ b/calamares/src/modules/localeq/localeq-qt6.qml @@ -0,0 +1,259 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 - 2022 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.kirigami as Kirigami + +Page { + id: root + width: parent.width + height: parent.height + + readonly property color headerBackgroundColor: Kirigami.Theme.alternateBackgroundColor //"#eff0f1" + readonly property color backgroundLighterColor: "#ffffff" + readonly property color highlightColor: Kirigami.Theme.highlightColor //"#3498DB" + readonly property color textColor: Kirigami.Theme.textColor //"#1F1F1F" + readonly property color highlightedTextColor: Kirigami.Theme.highlightedTextColor + + function onActivate() { + /* If you want the map to follow Calamares's GeoIP + * lookup or configuration, call the update function + * here, and disable the one at onCompleted in Map.qml. + */ + if (Network.hasInternet) { image.item.getIpOffline() } + } + + Loader { + id: image + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + height: parent.height / 1.28 + // Network is in io.calamares.core + source: Network.hasInternet ? "Map.qml" : "Offline.qml" + } + + RowLayout { + anchors.bottom: parent.bottom + anchors.bottomMargin : 20 + width: parent.width + spacing: 50 + + GridLayout { + rowSpacing: Kirigami.Units.largeSpacing + columnSpacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + source: "qrc:/img/locale.svg" + Layout.fillHeight: true + Layout.maximumHeight: Kirigami.Units.iconSizes.large + Layout.preferredWidth: height + } + + ColumnLayout { + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: config.currentLanguageStatus + } + Kirigami.Separator { + Layout.fillWidth: true + } + Button { + Layout.alignment: Qt.AlignRight|Qt.AlignVCenter + Layout.columnSpan: 2 + text: qsTr("Change", "@button") + onClicked: { + drawerLanguage.open() + } + } + } + } + + GridLayout { + rowSpacing: Kirigami.Units.largeSpacing + columnSpacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + source: "qrc:/img/locale.svg" + Layout.fillHeight: true + Layout.maximumHeight: Kirigami.Units.iconSizes.large + Layout.preferredWidth: height + } + ColumnLayout { + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: config.currentLCStatus + } + Kirigami.Separator { + Layout.fillWidth: true + } + Button { + Layout.alignment: Qt.AlignRight|Qt.AlignVCenter + Layout.columnSpan: 2 + text: qsTr("Change", "@button") + onClicked: { + drawerLocale.open() + } + } + } + } + + Drawer { + id: drawerLanguage + width: 0.33 * root.width + height: root.height + edge: Qt.LeftEdge + + ScrollView { + id: scroll1 + anchors.fill: parent + contentHeight: 800 + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: list1 + focus: true + clip: true + width: parent.width + + model: config.supportedLocales + currentIndex: -1 //config.localeIndex + + header: Rectangle { + width: parent.width + height: 100 + color: "#eff0f1" //headerBackgroundColor + Text { + anchors.fill: parent + wrapMode: Text.WordWrap + text: qsTr("

Languages


+ The system locale setting affects the language and character set for some command line user interface elements. The current setting is %1.", "@info").arg(config.currentLanguageCode) + font.pointSize: 10 + } + } + + delegate: ItemDelegate { + + property variant myData: model + hoverEnabled: true + width: drawerLanguage.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + Label { + Layout.fillHeight: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + width: parent.width + height: 24 + color: highlighted ? "#eff0f1" : "#1F1F1F" // headerBackgroundColor : textColor + text: modelData + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 0.9 + } + + MouseArea { + hoverEnabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + list1.currentIndex = index + drawerLanguage.close() + } + } + } + } + onCurrentItemChanged: { config.currentLanguageCode = model[currentIndex] } /* This works because model is a stringlist */ + } + } + } + + Drawer { + id: drawerLocale + width: 0.33 * root.width + height: root.height + edge: Qt.RightEdge + + ScrollView { + id: scroll2 + anchors.fill: parent + contentHeight: 800 + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: list2 + focus: true + clip: true + width: parent.width + + model: config.supportedLocales + currentIndex: -1 //model.currentLCCodeIndex + + header: Rectangle { + width: parent.width + height: 100 + color: "#eff0f1" // headerBackgroundColor + Text { + anchors.fill: parent + wrapMode: Text.WordWrap + text: qsTr("

Locales


+ The system locale setting affects the numbers and dates format. The current setting is %1.", "@info").arg(config.currentLCCode) + font.pointSize: 10 + } + } + + delegate: ItemDelegate { + + hoverEnabled: true + width: drawerLocale.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + Label { + Layout.fillHeight: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + width: parent.width + height: 24 + color: highlighted ? "#eff0f1" : "#1F1F1F" // headerBackgroundColor : textColor + text: modelData + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 0.9 + } + + MouseArea { + hoverEnabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + list2.currentIndex = index + drawerLocale.close() + } + } + } + } + onCurrentItemChanged: { config.currentLCCode = model[currentIndex]; } /* This works because model is a stringlist */ + } + } + } + } + Loader { + id:load + anchors.fill: parent + } +} diff --git a/calamares/src/modules/localeq/localeq-qt6.qrc b/calamares/src/modules/localeq/localeq-qt6.qrc new file mode 100644 index 0000000..e4414a2 --- /dev/null +++ b/calamares/src/modules/localeq/localeq-qt6.qrc @@ -0,0 +1,11 @@ + + + localeq-qt6.qml + Map-qt6.qml + Offline-qt6.qml + img/locale.svg + img/minus.png + img/pin.svg + img/plus.png + + diff --git a/calamares/src/modules/localeq/localeq.conf b/calamares/src/modules/localeq/localeq.conf new file mode 100644 index 0000000..bb2a7e8 --- /dev/null +++ b/calamares/src/modules/localeq/localeq.conf @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# This settings are used to set your default system time zone. +# Time zones are usually located under /usr/share/zoneinfo and +# provided by the 'tzdata' package of your Distribution. +# +# Distributions using systemd can list available +# time zones by using the timedatectl command. +# timedatectl list-timezones +# +# The starting timezone (e.g. the pin-on-the-map) when entering +# the locale page can be set through keys *region* and *zone*. +# If either is not set, defaults to America/New_York. +# +region: "America" +zone: "New_York" + + +# System locales are detected in the following order: +# +# - /usr/share/i18n/SUPPORTED +# - localeGenPath (defaults to /etc/locale.gen if not set) +# - 'locale -a' output +# +# Enable only when your Distribution is using an +# custom path for locale.gen +# +#localeGenPath: "PATH_TO/locale.gen" + +# GeoIP based Language settings: Leave commented out to disable GeoIP. +# +# GeoIP needs a working Internet connection. +# This can be managed from `welcome.conf` by adding +# internet to the list of required conditions. +# +# The configuration +# is in three parts: a *style*, which can be "json" or "xml" +# depending on the kind of data returned by the service, and +# a *url* where the data is retrieved, and an optional *selector* +# to pick the right field out of the returned data (e.g. field +# name in JSON or element name in XML). +# +# The default selector (when the setting is blank) is picked to +# work with existing JSON providers (which use "time_zone") and +# Ubiquity's XML providers (which use "TimeZone"). +# +# If the service configured via *url* uses +# a different attribute name (e.g. "timezone") in JSON or a +# different element tag (e.g. "") in XML, set this +# string to the name or tag to be used. +# +# In JSON: +# - if the string contains "." characters, this is used as a +# multi-level selector, e.g. "a.b" will select the timezone +# from data "{a: {b: "Europe/Amsterdam" } }". +# - each part of the string split by "." characters is used as +# a key into the JSON data. +# In XML: +# - all elements with the named tag (e.g. all TimeZone) elements +# from the document are checked; the first one with non-empty +# text value is used. +# +# +# An HTTP(S) request is made to *url*. The request should return +# valid data in a suitable format, depending on *style*; +# generally this includes a string value with the timezone +# in / format. For services that return data which +# does not follow the conventions of "suitable data" described +# below, *selector* may be used to pick different data. +# +# Note that this example URL works, but the service is shutting +# down in June 2018. +# +# Suitable JSON data looks like +# ``` +# {"time_zone":"America/New_York"} +# ``` +# Suitable XML data looks like +# ``` +# Europe/Brussels +# ``` +# +# To accommodate providers of GeoIP timezone data with peculiar timezone +# naming conventions, the following cleanups are performed automatically: +# - backslashes are removed +# - spaces are replaced with _ +# +# Legacy settings "geoipStyle", "geoipUrl" and "geoipSelector" +# in the top-level are still supported, but I'd advise against. +# +# To disable GeoIP checking, either comment-out the entire geoip section, +# or set the *style* key to an unsupported format (e.g. `none`). +# Also, note the analogous feature in src/modules/welcome/welcome.conf. +# +geoip: + style: "json" + url: "https://geoip.kde.org/v1/calamares" + selector: "" # leave blank for the default diff --git a/calamares/src/modules/localeq/localeq.qml b/calamares/src/modules/localeq/localeq.qml new file mode 100644 index 0000000..7467f7d --- /dev/null +++ b/calamares/src/modules/localeq/localeq.qml @@ -0,0 +1,259 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 - 2022 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.10 +import QtQuick.Controls 2.10 +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.7 as Kirigami + +Page { + id: root + width: parent.width + height: parent.height + + readonly property color headerBackgroundColor: Kirigami.Theme.alternateBackgroundColor //"#eff0f1" + readonly property color backgroundLighterColor: "#ffffff" + readonly property color highlightColor: Kirigami.Theme.highlightColor //"#3498DB" + readonly property color textColor: Kirigami.Theme.textColor //"#1F1F1F" + readonly property color highlightedTextColor: Kirigami.Theme.highlightedTextColor + + function onActivate() { + /* If you want the map to follow Calamares's GeoIP + * lookup or configuration, call the update function + * here, and disable the one at onCompleted in Map.qml. + */ + if (Network.hasInternet) { image.item.getIpOffline() } + } + + Loader { + id: image + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + height: parent.height / 1.28 + // Network is in io.calamares.core + source: Network.hasInternet ? "Map.qml" : "Offline.qml" + } + + RowLayout { + anchors.bottom: parent.bottom + anchors.bottomMargin : 20 + width: parent.width + spacing: 50 + + GridLayout { + rowSpacing: Kirigami.Units.largeSpacing + columnSpacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + source: "qrc:/img/locale.svg" + Layout.fillHeight: true + Layout.maximumHeight: Kirigami.Units.iconSizes.large + Layout.preferredWidth: height + } + + ColumnLayout { + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: config.currentLanguageStatus + } + Kirigami.Separator { + Layout.fillWidth: true + } + Button { + Layout.alignment: Qt.AlignRight|Qt.AlignVCenter + Layout.columnSpan: 2 + text: qsTr("Change", "@button") + onClicked: { + drawerLanguage.open() + } + } + } + } + + GridLayout { + rowSpacing: Kirigami.Units.largeSpacing + columnSpacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + source: "qrc:/img/locale.svg" + Layout.fillHeight: true + Layout.maximumHeight: Kirigami.Units.iconSizes.large + Layout.preferredWidth: height + } + ColumnLayout { + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: config.currentLCStatus + } + Kirigami.Separator { + Layout.fillWidth: true + } + Button { + Layout.alignment: Qt.AlignRight|Qt.AlignVCenter + Layout.columnSpan: 2 + text: qsTr("Change", "@button") + onClicked: { + drawerLocale.open() + } + } + } + } + + Drawer { + id: drawerLanguage + width: 0.33 * root.width + height: root.height + edge: Qt.LeftEdge + + ScrollView { + id: scroll1 + anchors.fill: parent + contentHeight: 800 + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: list1 + focus: true + clip: true + width: parent.width + + model: config.supportedLocales + currentIndex: -1 //config.localeIndex + + header: Rectangle { + width: parent.width + height: 100 + color: "#eff0f1" //headerBackgroundColor + Text { + anchors.fill: parent + wrapMode: Text.WordWrap + text: qsTr("

Languages


+ The system locale setting affects the language and character set for some command line user interface elements. The current setting is %1.", "@info").arg(config.currentLanguageCode) + font.pointSize: 10 + } + } + + delegate: ItemDelegate { + + property variant myData: model + hoverEnabled: true + width: drawerLanguage.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + Label { + Layout.fillHeight: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + width: parent.width + height: 24 + color: highlighted ? "#eff0f1" : "#1F1F1F" // headerBackgroundColor : textColor + text: modelData + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 0.9 + } + + MouseArea { + hoverEnabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + list1.currentIndex = index + drawerLanguage.close() + } + } + } + } + onCurrentItemChanged: { config.currentLanguageCode = model[currentIndex] } /* This works because model is a stringlist */ + } + } + } + + Drawer { + id: drawerLocale + width: 0.33 * root.width + height: root.height + edge: Qt.RightEdge + + ScrollView { + id: scroll2 + anchors.fill: parent + contentHeight: 800 + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView { + id: list2 + focus: true + clip: true + width: parent.width + + model: config.supportedLocales + currentIndex: -1 //model.currentLCCodeIndex + + header: Rectangle { + width: parent.width + height: 100 + color: "#eff0f1" // headerBackgroundColor + Text { + anchors.fill: parent + wrapMode: Text.WordWrap + text: qsTr("

Locales


+ The system locale setting affects the numbers and dates format. The current setting is %1.", "@info").arg(config.currentLCCode) + font.pointSize: 10 + } + } + + delegate: ItemDelegate { + + hoverEnabled: true + width: drawerLocale.width + implicitHeight: 24 + highlighted: ListView.isCurrentItem + Label { + Layout.fillHeight: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + width: parent.width + height: 24 + color: highlighted ? "#eff0f1" : "#1F1F1F" // headerBackgroundColor : textColor + text: modelData + background: Rectangle { + + color: highlighted || hovered ? highlightColor : backgroundLighterColor + opacity: highlighted || hovered ? 0.5 : 0.9 + } + + MouseArea { + hoverEnabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + list2.currentIndex = index + drawerLocale.close() + } + } + } + } + onCurrentItemChanged: { config.currentLCCode = model[currentIndex]; } /* This works because model is a stringlist */ + } + } + } + } + Loader { + id:load + anchors.fill: parent + } +} diff --git a/calamares/src/modules/localeq/localeq.qrc b/calamares/src/modules/localeq/localeq.qrc new file mode 100644 index 0000000..af6f7e9 --- /dev/null +++ b/calamares/src/modules/localeq/localeq.qrc @@ -0,0 +1,11 @@ + + + localeq.qml + Map.qml + Offline.qml + img/locale.svg + img/minus.png + img/pin.svg + img/plus.png + + diff --git a/calamares/src/modules/luksbootkeyfile/CMakeLists.txt b/calamares/src/modules/luksbootkeyfile/CMakeLists.txt new file mode 100644 index 0000000..735d317 --- /dev/null +++ b/calamares/src/modules/luksbootkeyfile/CMakeLists.txt @@ -0,0 +1,14 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(luksbootkeyfile + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + LuksBootKeyFileJob.cpp + SHARED_LIB +) + +calamares_add_test(luksbootkeyfiletest SOURCES Tests.cpp LuksBootKeyFileJob.cpp) diff --git a/calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.cpp b/calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.cpp new file mode 100644 index 0000000..620957d --- /dev/null +++ b/calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.cpp @@ -0,0 +1,343 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "LuksBootKeyFileJob.h" + +#include "utils/Entropy.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/System.h" +#include "utils/UMask.h" +#include "utils/Variant.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include +#include + +LuksBootKeyFileJob::LuksBootKeyFileJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +LuksBootKeyFileJob::~LuksBootKeyFileJob() {} + +QString +LuksBootKeyFileJob::prettyName() const +{ + return tr( "Configuring LUKS key file." ); +} + +struct LuksDevice +{ + LuksDevice( const QMap< QString, QVariant >& pinfo ) + : isValid( false ) + , isRoot( false ) + { + if ( pinfo.contains( "luksMapperName" ) ) + { + QString fs = pinfo[ "fs" ].toString(); + QString mountPoint = pinfo[ "mountPoint" ].toString(); + + if ( !mountPoint.isEmpty() || fs == QStringLiteral( "linuxswap" ) ) + { + isValid = true; + isRoot = mountPoint == '/'; + device = pinfo[ "device" ].toString(); + passphrase = pinfo[ "luksPassphrase" ].toString(); + } + } + } + + bool isValid; + bool isRoot; + QString device; + QString passphrase; +}; + +/** @brief Extract the luks passphrases setup. + * + * Given a list of partitions (as set up by the partitioning module, + * so there's maps with keys inside), returns just the list of + * luks passphrases for each device. + */ +static QList< LuksDevice > +getLuksDevices( const QVariantList& list ) +{ + QList< LuksDevice > luksItems; + + for ( const auto& p : list ) + { + if ( p.canConvert< QVariantMap >() ) + { + LuksDevice d( p.toMap() ); + if ( d.isValid ) + { + luksItems.append( d ); + } + } + } + return luksItems; +} + +struct LuksDeviceList +{ + LuksDeviceList( const QVariant& partitions ) + : valid( false ) + { + if ( partitions.canConvert< QVariantList >() ) + { + devices = getLuksDevices( partitions.toList() ); + valid = true; + } + } + + QList< LuksDevice > devices; + bool valid; +}; + +static const char keyfile[] = "/crypto_keyfile.bin"; + +static bool +generateTargetKeyfile() +{ + Calamares::UMask m( Calamares::UMask::Safe ); + + // Get the data + QByteArray entropy; + auto entropySource = Calamares::getEntropy( 2048, entropy ); + if ( entropySource != Calamares::EntropySource::URandom ) + { + cWarning() << "Could not get entropy from /dev/urandom for LUKS."; + return false; + } + + auto fileResult + = Calamares::System::instance()->createTargetFile( keyfile, entropy, Calamares::System::WriteMode::Overwrite ); + entropy.fill( 'A' ); + if ( !fileResult ) + { + cWarning() << "Could not create LUKS keyfile:" << smash( fileResult.code() ); + return false; + } + + // Give ample time to check that the file was created correctly; + // we actually expect ls to return pretty-much-instantly. + auto r = Calamares::System::instance()->targetEnvCommand( + { "ls", "-la", "/" }, QString(), QString(), std::chrono::seconds( 5 ) ); + cDebug() << "In target system after creating LUKS file" << r.getOutput(); + return true; +} + +static bool +setupLuks( const LuksDevice& d, const QString& luks2Hash ) +{ + // Get luksDump for this device + auto luks_dump = Calamares::System::instance()->targetEnvCommand( + { QStringLiteral( "cryptsetup" ), QStringLiteral( "luksDump" ), d.device }, + QString(), + QString(), + std::chrono::seconds( 5 ) ); + if ( luks_dump.getExitCode() != 0 ) + { + cWarning() << "Could not get LUKS information on " << d.device << ':' << luks_dump.getOutput() << "(exit code" + << luks_dump.getExitCode() << ')'; + return false; + } + + // Check LUKS version + int luks_version = 0; + QRegularExpression version_re( QStringLiteral( R"(version:\s*([0-9]))" ), + QRegularExpression::CaseInsensitiveOption ); + QRegularExpressionMatch match = version_re.match( luks_dump.getOutput() ); + if ( !match.hasMatch() ) + { + cWarning() << "Could not get LUKS version on device: " << d.device; + return false; + } + bool ok; + luks_version = match.captured( 1 ).toInt( &ok ); + if ( !ok ) + { + cWarning() << "Could not get LUKS version on device: " << d.device; + return false; + } + cDebug() << "LUKS" << luks_version << " found on device: " << d.device; + + // Check the number of slots used for LUKS1 devices + if ( luks_version == 1 ) + { + QRegularExpression slots_re( QStringLiteral( R"(\d+:\s*enabled)" ), QRegularExpression::CaseInsensitiveOption ); + if ( luks_dump.getOutput().count( slots_re ) == 8 ) + { + cWarning() << "No key slots left on LUKS1 device: " << d.device; + return false; + } + } + + // Add the key to the keyfile + QStringList args = { QStringLiteral( "cryptsetup" ), QStringLiteral( "luksAddKey" ), d.device, keyfile }; + if ( luks_version == 2 && luks2Hash != QString() ) + { + args.insert( 2, "--pbkdf" ); + args.insert( 3, luks2Hash ); + } + auto r + = Calamares::System::instance()->targetEnvCommand( args, QString(), d.passphrase, std::chrono::seconds( 60 ) ); + if ( r.getExitCode() != 0 ) + { + cWarning() << "Could not configure LUKS keyfile on" << d.device << ':' << r.getOutput() << "(exit code" + << r.getExitCode() << ')'; + return false; + } + return true; +} + +static QVariantList +partitionsFromGlobalStorage() +{ + Calamares::GlobalStorage* globalStorage = Calamares::JobQueue::instance()->globalStorage(); + return globalStorage->value( QStringLiteral( "partitions" ) ).toList(); +} + +/// Checks if the partition (represented by @p map) mounts to the given @p path +STATICTEST bool +hasMountPoint( const QVariantMap& map, const QString& path ) +{ + const auto v = map.value( QStringLiteral( "mountPoint" ) ); + return v.isValid() && QDir::cleanPath( v.toString() ) == path; +} + +STATICTEST bool +isEncrypted( const QVariantMap& map ) +{ + return map.contains( QStringLiteral( "luksMapperName" ) ); +} + +/// Checks for any partition satisfying @p pred +STATICTEST bool +anyPartition( bool ( *pred )( const QVariantMap& ) ) +{ + const auto partitions = partitionsFromGlobalStorage(); + return std::find_if( partitions.cbegin(), + partitions.cend(), + [ &pred ]( const QVariant& partitionVariant ) { return pred( partitionVariant.toMap() ); } ) + != partitions.cend(); +} + +STATICTEST bool +hasUnencryptedSeparateBoot() +{ + return anyPartition( + []( const QVariantMap& partition ) + { return hasMountPoint( partition, QStringLiteral( "/boot" ) ) && !isEncrypted( partition ); } ); +} + +Calamares::JobResult +LuksBootKeyFileJob::exec() +{ + const auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( !gs ) + { + return Calamares::JobResult::internalError( + "LuksBootKeyFile", "No GlobalStorage defined.", Calamares::JobResult::InvalidConfiguration ); + } + if ( !gs->contains( "partitions" ) ) + { + cError() << "No GS[partitions] key."; + return Calamares::JobResult::internalError( + "LuksBootKeyFile", tr( "No partitions are defined." ), Calamares::JobResult::InvalidConfiguration ); + } + + LuksDeviceList s( gs->value( "partitions" ) ); + if ( !s.valid ) + { + cError() << "GS[partitions] is invalid"; + return Calamares::JobResult::internalError( + "LuksBootKeyFile", tr( "No partitions are defined." ), Calamares::JobResult::InvalidConfiguration ); + } + + cDebug() << "There are" << s.devices.count() << "LUKS partitions"; + if ( s.devices.count() < 1 ) + { + cDebug() << Logger::SubEntry << "Nothing to do for LUKS."; + return Calamares::JobResult::ok(); + } + + auto it = std::partition( s.devices.begin(), s.devices.end(), []( const LuksDevice& d ) { return d.isRoot; } ); + for ( const auto& d : s.devices ) + { + cDebug() << Logger::SubEntry << ( d.isRoot ? "root" : "dev." ) << d.device << "passphrase?" + << !d.passphrase.isEmpty(); + } + + if ( it == s.devices.begin() ) + { + // User has configured non-root partition for encryption + cDebug() << Logger::SubEntry << "No root partition, skipping keyfile creation."; + return Calamares::JobResult::ok(); + } + + if ( hasUnencryptedSeparateBoot() ) + { + // /boot partition is not encrypted, keyfile must not be used. + cDebug() << Logger::SubEntry << "/boot partition is not encrypted, skipping keyfile creation."; + return Calamares::JobResult::ok(); + } + + if ( s.devices.first().passphrase.isEmpty() ) + { + cDebug() << Logger::SubEntry << "No root passphrase."; + return Calamares::JobResult::error( + tr( "Encrypted rootfs setup error" ), + tr( "Root partition %1 is LUKS but no passphrase has been set." ).arg( s.devices.first().device ) ); + } + + if ( !generateTargetKeyfile() ) + { + return Calamares::JobResult::error( + tr( "Encrypted rootfs setup error" ), + tr( "Could not create LUKS key file for root partition %1." ).arg( s.devices.first().device ) ); + } + + for ( const auto& d : s.devices ) + { + // Skip setupLuks for root partition if system has an unencrypted /boot + if ( d.isRoot && hasUnencryptedSeparateBoot() ) + { + continue; + } + + if ( !setupLuks( d, m_luks2Hash ) ) + { + // Could not configure the LUKS partition + // This should not stop the installation: do not return Calamares::JobResult::error. + cError() << "Encrypted rootfs setup error: could not configure LUKS key file on partition " << d.device; + } + } + + return Calamares::JobResult::ok(); +} + +void +LuksBootKeyFileJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + // Map the value from the config file to accepted values; + // this is an immediately-invoked lambda which is passed the + // return value of getString(). + m_luks2Hash = []( const QString& value ) + { + if ( value == QStringLiteral( "default" ) ) + { + return QString(); // Empty is used internally for "default from cryptsetup" + } + return value.toLower(); + }( Calamares::getString( configurationMap, QStringLiteral( "luks2Hash" ), QString() ) ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( LuksBootKeyFileJobFactory, registerPlugin< LuksBootKeyFileJob >(); ) diff --git a/calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.h b/calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.h new file mode 100644 index 0000000..05288a1 --- /dev/null +++ b/calamares/src/modules/luksbootkeyfile/LuksBootKeyFileJob.h @@ -0,0 +1,42 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#ifndef LUKSBOOTKEYFILEJOB_H +#define LUKSBOOTKEYFILEJOB_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include + +/** @brief Creates the LUKS boot key file and adds it to the cryptsetup. + * + * This job has no configuration, because it takes everything + * from the global storage settings set by others. + */ +class PLUGINDLLEXPORT LuksBootKeyFileJob : public Calamares::CppJob +{ + Q_OBJECT +public: + explicit LuksBootKeyFileJob( QObject* parent = nullptr ); + ~LuksBootKeyFileJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_luks2Hash; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( LuksBootKeyFileJobFactory ) + +#endif // LUKSBOOTKEYFILEJOB_H diff --git a/calamares/src/modules/luksbootkeyfile/Tests.cpp b/calamares/src/modules/luksbootkeyfile/Tests.cpp new file mode 100644 index 0000000..07eacd8 --- /dev/null +++ b/calamares/src/modules/luksbootkeyfile/Tests.cpp @@ -0,0 +1,168 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" + +#include + +#undef STATICTEST +#define STATICTEST extern + +// Implementation details +STATICTEST bool hasMountPoint( const QVariantMap& map, const QString& path ); + +STATICTEST bool isEncrypted( const QVariantMap& map ); + +STATICTEST bool anyPartition( bool ( *pred )( const QVariantMap& ) ); + +STATICTEST bool hasUnencryptedSeparateBoot(); + +STATICTEST bool hasEncryptedRoot(); + +class LuksBootKeyFileTests : public QObject +{ + Q_OBJECT +public: + LuksBootKeyFileTests() {} + ~LuksBootKeyFileTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testMountPoint(); + void testIsEncrypted(); + void testAnyPartition(); +}; + +void +LuksBootKeyFileTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "LuksBootKeyFile test started."; + + if ( !Calamares::JobQueue::instance() ) + { + (void)new Calamares::JobQueue(); + } +} + +void +LuksBootKeyFileTests::testMountPoint() +{ + QVariantMap m; // As if this is a partition data + const QString key = QStringLiteral( "mountPoint" ); + const QString boot = QStringLiteral( "/boot" ); + const QString root = QStringLiteral( "/" ); + + QVERIFY( !hasMountPoint( m, QString() ) ); + QVERIFY( !hasMountPoint( m, boot ) ); + + m.insert( key, boot ); + QVERIFY( hasMountPoint( m, boot ) ); + QVERIFY( !hasMountPoint( m, QString() ) ); + QVERIFY( !hasMountPoint( m, root ) ); + + m.insert( key, root ); + QVERIFY( !hasMountPoint( m, boot ) ); + QVERIFY( !hasMountPoint( m, QString() ) ); + QVERIFY( hasMountPoint( m, root ) ); + + m.remove( key ); + QVERIFY( !hasMountPoint( m, root ) ); +} + +void +LuksBootKeyFileTests::testIsEncrypted() +{ + QVariantMap m; // As if this is a partition data + const QString key = QStringLiteral( "luksMapperName" ); + const QString name = QStringLiteral( "any-name" ); + + QVERIFY( !isEncrypted( m ) ); + + // Even an empty string is considered encrypted + m.insert( key, QString() ); + QVERIFY( isEncrypted( m ) ); + + m.insert( key, name ); + QVERIFY( isEncrypted( m ) ); + + m.insert( key, QString() ); + QVERIFY( isEncrypted( m ) ); + + m.remove( key ); + QVERIFY( !isEncrypted( m ) ); +} + + +void +LuksBootKeyFileTests::testAnyPartition() +{ + // This is kind of annoying: we need to build up + // partition data in GS because the functions we're testing + // go straight to GS. + auto* gs = Calamares::JobQueue::instanceGlobalStorage(); + QVERIFY( gs ); + + const QString partitionsKey = QStringLiteral( "partitions" ); + const QString mountPointKey = QStringLiteral( "mountPoint" ); + const QString boot = QStringLiteral( "/boot" ); + const QString root = QStringLiteral( "/" ); + + QVariantList partitions; + QVariantMap p; + QVERIFY( !gs->contains( partitionsKey ) ); + + // Empty list! + QVERIFY( !anyPartition( []( const QVariantMap& ) { return true; } ) ); + + gs->insert( partitionsKey, partitions ); + QVERIFY( !anyPartition( []( const QVariantMap& ) { return true; } ) ); // Still an empty list + + partitions.append( p ); + QCOMPARE( partitions.count(), 1 ); + gs->insert( partitionsKey, partitions ); + QVERIFY( anyPartition( []( const QVariantMap& ) { return true; } ) ); // Now a one-element list + QVERIFY( !anyPartition( []( const QVariantMap& ) { return false; } ) ); // Now a one-element list + + p.insert( mountPointKey, boot ); + QVERIFY( hasMountPoint( p, boot ) ); + partitions.append( p ); + QCOMPARE( partitions.count(), 2 ); + + // Note that GS is not updated yet, so we expect this to fail + QEXPECT_FAIL( "", "GS not updated", Continue ); + QVERIFY( anyPartition( + []( const QVariantMap& partdata ) + { + cDebug() << partdata; + return hasMountPoint( partdata, QStringLiteral( "/boot" ) ); + } ) ); + + gs->insert( partitionsKey, partitions ); // Update GS + QVERIFY( anyPartition( + []( const QVariantMap& partdata ) + { + cDebug() << partdata; + return hasMountPoint( partdata, QStringLiteral( "/boot" ) ); + } ) ); + QVERIFY( !anyPartition( []( const QVariantMap& partdata ) + { return hasMountPoint( partdata, QStringLiteral( "/" ) ); } ) ); + QVERIFY( !anyPartition( []( const QVariantMap& partdata ) { return hasMountPoint( partdata, QString() ); } ) ); + + QVERIFY( hasUnencryptedSeparateBoot() ); +} + +QTEST_GUILESS_MAIN( LuksBootKeyFileTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/luksbootkeyfile/luksbootkeyfile.conf b/calamares/src/modules/luksbootkeyfile/luksbootkeyfile.conf new file mode 100644 index 0000000..477d0e3 --- /dev/null +++ b/calamares/src/modules/luksbootkeyfile/luksbootkeyfile.conf @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Luksbootkeyfile configuration. A key file is created for the +# LUKS encrypted devices. +--- +# Set Password-Based Key Derivation Function (PBKDF) algorithm +# for LUKS keyslot. +# +# There are three usable specific values: pbkdf2, argon2i or argon2id. +# There is one value equivalent to not setting it: default +# +# When not set (or explicitly set to "default"), the cryptsetup default is used +luks2Hash: default diff --git a/calamares/src/modules/luksbootkeyfile/luksbootkeyfile.schema.yaml b/calamares/src/modules/luksbootkeyfile/luksbootkeyfile.schema.yaml new file mode 100644 index 0000000..71d26ea --- /dev/null +++ b/calamares/src/modules/luksbootkeyfile/luksbootkeyfile.schema.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2023 Arjen Balfoort +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/luksbootkeyfile +additionalProperties: false +type: object +properties: + luks2Hash: { type: string, enum: [ pbkdf2, argon2i, argon2id, default ] } diff --git a/calamares/src/modules/luksopenswaphookcfg/CMakeLists.txt b/calamares/src/modules/luksopenswaphookcfg/CMakeLists.txt new file mode 100644 index 0000000..f186fbd --- /dev/null +++ b/calamares/src/modules/luksopenswaphookcfg/CMakeLists.txt @@ -0,0 +1,17 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2021 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# Because LUKS Open Swap Hook (Job) is such a mouthful, we'll +# use LOSH all over the place as a shorthand. +calamares_add_plugin(luksopenswaphookcfg + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + LOSHJob.cpp + SHARED_LIB +) + +calamares_add_test(luksopenswaphooktest SOURCES LOSHJob.cpp Tests.cpp) diff --git a/calamares/src/modules/luksopenswaphookcfg/LOSHInfo.h b/calamares/src/modules/luksopenswaphookcfg/LOSHInfo.h new file mode 100644 index 0000000..1a87f4e --- /dev/null +++ b/calamares/src/modules/luksopenswaphookcfg/LOSHInfo.h @@ -0,0 +1,66 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ +#ifndef LUKSOPENSWAPHOOKCFG_LOSHINFO_H +#define LUKSOPENSWAPHOOKCFG_LOSHINFO_H + +#include + +/** @brief Information needed to create a suitable config file + * + * The LUKS swap configuration has a handful of keys that need to + * be written to the config file. This struct holds those keys + * and can find the key values from Global Storage (where the + * *partition* module sets them). + */ +struct LOSHInfo +{ + // Member names copied from Python code + QString swap_outer_uuid; + QString swap_mapper_name; + QString mountable_keyfile_device; + QString swap_device_path; + QString keyfile_device_mount_options; + + bool isValid() const { return !swap_device_path.isEmpty(); } + + /** @brief Helper method for doing key-value replacements + * + * Given a named @p key (e.g. "duck", or "swap_device"), returns the + * value set for that key. Invalid keys (e.g. "duck") return an empty string. + */ + QString replacementFor( const QString& key ) const + { + if ( key == QStringLiteral( "swap_device" ) ) + { + return swap_device_path; + } + if ( key == QStringLiteral( "crypt_swap_name" ) ) + { + return swap_mapper_name; + } + if ( key == QStringLiteral( "keyfile_device" ) ) + { + return mountable_keyfile_device; + } + if ( key == QStringLiteral( "keyfile_filename" ) ) + { + return QStringLiteral( "crypto_keyfile.bin" ); + } + if ( key == QStringLiteral( "keyfile_device_mount_options" ) ) + { + return keyfile_device_mount_options; + } + return QString(); + } + + /** @brief Creates a struct from information already set in GS + * + */ + static LOSHInfo fromGlobalStorage(); +}; + +#endif diff --git a/calamares/src/modules/luksopenswaphookcfg/LOSHJob.cpp b/calamares/src/modules/luksopenswaphookcfg/LOSHJob.cpp new file mode 100644 index 0000000..5913262 --- /dev/null +++ b/calamares/src/modules/luksopenswaphookcfg/LOSHJob.cpp @@ -0,0 +1,179 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ +#include "LOSHJob.h" + +#include "LOSHInfo.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/Permissions.h" +#include "utils/PluginFactory.h" +#include "utils/String.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include +#include +#include +#include + +LOSHJob::LOSHJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +LOSHJob::~LOSHJob() {} + +QString +LOSHJob::prettyName() const +{ + return tr( "Configuring encrypted swap." ); +} + +STATICTEST QString +get_assignment_part( const QString& line ) +{ + static QRegularExpression re( "^[# \\t]*([A-Za-z_]+)[ \\t]*=" ); + auto m = re.match( line ); + if ( m.hasMatch() ) + { + return m.captured( 1 ); + } + return QString(); +} + +/** Writes the config file at @p path + * + * NOTE: @p path is relative to the target system, not an absolute path. + */ +STATICTEST void +write_openswap_conf( const QString& path, QStringList& contents, const LOSHInfo& info ) +{ + if ( info.isValid() ) + { + for ( auto& line : contents ) + { + const QString key = get_assignment_part( line ); + QString replacement = info.replacementFor( key ); + if ( !replacement.isEmpty() ) + { + line.clear(); + line.append( QStringLiteral( "%1=%2" ).arg( key, replacement ) ); + } + } + cDebug() << "Writing" << contents.length() << "line configuration to" << path; + // \n between each two lines, and a \n at the end + Calamares::System::instance()->createTargetFile( + path, contents.join( '\n' ).append( '\n' ).toUtf8(), Calamares::System::WriteMode::Overwrite ); + } + else + { + cDebug() << "Will not write an invalid configuration to" << path; + } +} + +Calamares::JobResult +LOSHJob::exec() +{ + const auto* sys = Calamares::System::instance(); + if ( !sys ) + { + return Calamares::JobResult::internalError( + "LuksOpenSwapHook", tr( "No target system available." ), Calamares::JobResult::InvalidConfiguration ); + } + + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + if ( !gs || gs->value( "rootMountPoint" ).toString().isEmpty() ) + { + return Calamares::JobResult::internalError( + "LuksOpenSwapHook", tr( "No rootMountPoint is set." ), Calamares::JobResult::InvalidConfiguration ); + } + if ( m_configFilePath.isEmpty() ) + { + return Calamares::JobResult::internalError( + "LuksOpenSwapHook", tr( "No configFilePath is set." ), Calamares::JobResult::InvalidConfiguration ); + } + + QStringList contents = sys->readTargetFile( m_configFilePath ); + if ( contents.isEmpty() ) + { + contents << QStringLiteral( "# swap_device=" ) << QStringLiteral( "# crypt_swap_name=" ) + << QStringLiteral( "# keyfile_device=" ) << QStringLiteral( "# keyfile_filename=" ) + << QStringLiteral( "# keyfile_device_mount_options" ); + } + + write_openswap_conf( m_configFilePath, contents, LOSHInfo::fromGlobalStorage() ); + return Calamares::JobResult::ok(); +} + +void +LOSHJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_configFilePath = Calamares::getString( + configurationMap, QStringLiteral( "configFilePath" ), QStringLiteral( "/etc/openswap.conf" ) ); +} + +STATICTEST void +globalStoragePartitionInfo( Calamares::GlobalStorage* gs, LOSHInfo& info ) +{ + if ( !gs ) + { + return; + } + QVariantList l = gs->value( "partitions" ).toList(); + if ( l.isEmpty() ) + { + return; + } + + for ( const auto& pv : l ) + { + const QVariantMap partition = pv.toMap(); + if ( !partition.isEmpty() ) + { + QString mountPoint = partition.value( "mountPoint" ).toString(); + QString fileSystem = partition.value( "fs" ).toString(); + QString luksMapperName = partition.value( "luksMapperName" ).toString(); + // if partition["fs"] == "linuxswap" and "luksMapperName" in partition: + if ( fileSystem == QStringLiteral( "linuxswap" ) && !luksMapperName.isEmpty() ) + { + info.swap_outer_uuid = partition.value( "luksUuid" ).toString(); + info.swap_mapper_name = luksMapperName; + } + else if ( mountPoint == QStringLiteral( "/" ) && !luksMapperName.isEmpty() ) + { + + info.mountable_keyfile_device = QStringLiteral( "/dev/mapper/" ) + luksMapperName; + } + } + } + + if ( !info.mountable_keyfile_device.isEmpty() && !info.swap_outer_uuid.isEmpty() ) + { + info.swap_device_path = QStringLiteral( "/dev/disk/by-uuid/" ) + info.swap_outer_uuid; + } + + QString btrfsRootSubvolume = gs->value( "btrfsRootSubvolume" ).toString(); + if ( !btrfsRootSubvolume.isEmpty() ) + { + Calamares::String::removeLeading( btrfsRootSubvolume, '/' ); + info.keyfile_device_mount_options = QStringLiteral( "--options=subvol=" ) + btrfsRootSubvolume; + } +} + +LOSHInfo +LOSHInfo::fromGlobalStorage() +{ + LOSHInfo i {}; + globalStoragePartitionInfo( + Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr, i ); + return i; +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( LOSHJobFactory, registerPlugin< LOSHJob >(); ) diff --git a/calamares/src/modules/luksopenswaphookcfg/LOSHJob.h b/calamares/src/modules/luksopenswaphookcfg/LOSHJob.h new file mode 100644 index 0000000..4a435a9 --- /dev/null +++ b/calamares/src/modules/luksopenswaphookcfg/LOSHJob.h @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ +#ifndef LUKSOPENSWAPHOOKCFG_LOSHJOB_H +#define LUKSOPENSWAPHOOKCFG_LOSHJOB_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include + +class PLUGINDLLEXPORT LOSHJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit LOSHJob( QObject* parent = nullptr ); + ~LOSHJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_configFilePath; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( LOSHJobFactory ) + +#endif diff --git a/calamares/src/modules/luksopenswaphookcfg/Tests.cpp b/calamares/src/modules/luksopenswaphookcfg/Tests.cpp new file mode 100644 index 0000000..094b113 --- /dev/null +++ b/calamares/src/modules/luksopenswaphookcfg/Tests.cpp @@ -0,0 +1,248 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "LOSHInfo.h" +#include "LOSHJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include + +// LOSH = LUKS Open Swap Hook (Job) + +// Implementation details +extern QString get_assignment_part( const QString& line ); +extern void write_openswap_conf( const QString& path, QStringList& contents, const LOSHInfo& info ); + +class LOSHTests : public QObject +{ + Q_OBJECT +public: + LOSHTests(); + ~LOSHTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testAssignmentExtraction_data(); + void testAssignmentExtraction(); + + void testLOSHInfo(); + void testConfigWriting(); + void testJob(); +}; + +LOSHTests::LOSHTests() {} + +void +LOSHTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "LOSH test started."; +} + +void +LOSHTests::testAssignmentExtraction_data() +{ + QTest::addColumn< QString >( "line" ); + QTest::addColumn< QString >( "match" ); + + QTest::newRow( "empty" ) << QString() << QString(); + QTest::newRow( "comment-only1" ) << QStringLiteral( "# " ) << QString(); + QTest::newRow( "comment-only2" ) << QStringLiteral( "###" ) << QString(); + QTest::newRow( "comment-only3" ) << QStringLiteral( "# # #" ) << QString(); + + QTest::newRow( "comment-text" ) << QStringLiteral( "# NOTE:" ) << QString(); + QTest::newRow( "comment-story" ) << QStringLiteral( "# This is a shell comment" ) << QString(); + // We look for assignments, but only for single-words + QTest::newRow( "comment-space-eq" ) << QStringLiteral( "# Check that a = b" ) << QString(); + + QTest::newRow( "assignment1" ) << QStringLiteral( "a=1" ) << QStringLiteral( "a" ); + QTest::newRow( "assignment2" ) << QStringLiteral( "a = 1" ) << QStringLiteral( "a" ); + QTest::newRow( "assignment3" ) << QStringLiteral( "# a=1" ) << QStringLiteral( "a" ); + QTest::newRow( "assignment4" ) << QStringLiteral( "cows = 12" ) << QStringLiteral( "cows" ); + QTest::newRow( "assignment5" ) << QStringLiteral( "# # cows=1" ) << QStringLiteral( "cows" ); + QTest::newRow( "assignment6" ) << QStringLiteral( "# moose='cool' # not cows" ) << QStringLiteral( "moose" ); + QTest::newRow( "assignment7" ) << QStringLiteral( " moose=cows=42" ) << QStringLiteral( "moose" ); + QTest::newRow( "assignment8" ) << QStringLiteral( "#swap_device=/dev/something" ) + << QStringLiteral( "swap_device" ); + QTest::newRow( "assignment9" ) << QStringLiteral( "# swap_device=/dev/something" ) + << QStringLiteral( "swap_device" ); + QTest::newRow( "assignment10" ) << QStringLiteral( "swap_device=/dev/something" ) + << QStringLiteral( "swap_device" ); +} + +void +LOSHTests::testAssignmentExtraction() +{ + QFETCH( QString, line ); + QFETCH( QString, match ); + + QCOMPARE( get_assignment_part( line ), match ); +} + +static Calamares::System* +file_setup( const QTemporaryDir& tempRoot ) +{ + Calamares::System* ss = Calamares::System::instance(); + if ( !ss ) + { + ss = new Calamares::System( true ); + } + + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + if ( !gs ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + gs = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + } + if ( gs ) + { + // Working with a rootMountPoint set + gs->insert( "rootMountPoint", tempRoot.path() ); + } + return ss; +} + +static void +make_valid_loshinfo( LOSHInfo& i ) +{ + i.swap_outer_uuid = QStringLiteral( "UUID-0000" ); + i.swap_mapper_name = QStringLiteral( "/dev/mapper/0000" ); + i.swap_device_path = QStringLiteral( "/dev/sda0" ); + i.mountable_keyfile_device = QStringLiteral( "/dev/ada0p0s0" ); +} + +void +LOSHTests::testLOSHInfo() +{ + LOSHInfo i {}; + QVERIFY( !i.isValid() ); + + make_valid_loshinfo( i ); + QVERIFY( i.isValid() ); + QCOMPARE( i.replacementFor( QStringLiteral( "swap_device" ) ), QStringLiteral( "/dev/sda0" ) ); + QCOMPARE( i.replacementFor( QStringLiteral( "duck" ) ), QString() ); +} + +void +LOSHTests::testConfigWriting() +{ + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + QVERIFY( tempRoot.isValid() ); + auto* ss = file_setup( tempRoot ); + QVERIFY( ss ); + QVERIFY( Calamares::JobQueue::instance()->globalStorage() ); + QVERIFY( QFile::exists( tempRoot.path() ) ); + QVERIFY( QFileInfo( tempRoot.path() ).isDir() ); + + const QString targetFilePath = QStringLiteral( "losh.conf" ); + const QString filePath = tempRoot.filePath( targetFilePath ); + QStringList contents { QStringLiteral( "# Calamares demo" ), + QStringLiteral( "# swap_device=a thing" ), + QStringLiteral( "# duck duck swap_device=another" ) }; + + // When the information is invalid, file contents are unchanged, + // and no file is written either. + LOSHInfo i {}; + QVERIFY( !i.isValid() ); + QVERIFY( !QFile::exists( filePath ) ); + write_openswap_conf( targetFilePath, contents, i ); // Invalid i + QVERIFY( !QFile::exists( filePath ) ); + QCOMPARE( contents.length(), 3 ); + QCOMPARE( contents.at( 1 ).left( 4 ), QStringLiteral( "# s" ) ); + + // Can we write there at all? + QFile derp( filePath ); + QVERIFY( derp.open( QIODevice::WriteOnly ) ); + QVERIFY( derp.write( "xx", 2 ) ); + derp.close(); + QVERIFY( QFile::exists( filePath ) ); + QVERIFY( QFile::remove( filePath ) ); + + // Once the information is valid, though, the file is written + make_valid_loshinfo( i ); + QVERIFY( i.isValid() ); + QVERIFY( !QFile::exists( filePath ) ); + write_openswap_conf( targetFilePath, contents, i ); // Now it is valid + QVERIFY( QFile::exists( filePath ) ); + QCOMPARE( contents.length(), 3 ); + QCOMPARE( i.swap_device_path, QStringLiteral( "/dev/sda0" ) ); // expected key value + QCOMPARE( contents.at( 1 ), QStringLiteral( "swap_device=/dev/sda0" ) ); // expected line + + // readLine() returns with newlines-added + QFile f( filePath ); + QVERIFY( f.open( QIODevice::ReadOnly ) ); + QCOMPARE( f.readLine(), QStringLiteral( "# Calamares demo\n" ) ); + QCOMPARE( f.readLine(), QStringLiteral( "swap_device=/dev/sda0\n" ) ); + QCOMPARE( f.readLine(), QStringLiteral( "# duck duck swap_device=another\n" ) ); + QCOMPARE( f.readLine(), QString() ); + QVERIFY( f.atEnd() ); + + // Note how the contents is updated on every write_openswap_conf() + i.swap_device_path = QStringLiteral( "/dev/zram/0.zram" ); + write_openswap_conf( targetFilePath, contents, i ); // Still valid + QCOMPARE( contents.length(), 3 ); + QCOMPARE( i.swap_device_path, QStringLiteral( "/dev/zram/0.zram" ) ); // expected key value + QCOMPARE( contents.at( 1 ), QStringLiteral( "swap_device=/dev/zram/0.zram" ) ); // expected line +} + +void +LOSHTests::testJob() +{ + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + QVERIFY( tempRoot.isValid() ); + auto* ss = file_setup( tempRoot ); + QVERIFY( ss ); + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); + + { + QDir d( tempRoot.path() ); + d.mkdir( "etc" ); + } + + QVERIFY( !LOSHInfo::fromGlobalStorage().isValid() ); + QVariantList outerPartition; + QVariantMap innerPartition; + innerPartition.insert( "mountPoint", "/" ); + innerPartition.insert( "fs", "ext4" ); + innerPartition.insert( "luksMapperName", "root" ); + innerPartition.insert( "luksUUID", "0000" ); + outerPartition.append( innerPartition ); + innerPartition.remove( "mountPoint" ); + innerPartition.insert( "fs", "linuxswap" ); + innerPartition.insert( "luksMapperName", "swap" ); + innerPartition.insert( "luksUuid", "0001" ); + outerPartition.append( innerPartition ); + gs->insert( "partitions", outerPartition ); + QVERIFY( LOSHInfo::fromGlobalStorage().isValid() ); + + LOSHJob j; + j.setConfigurationMap( QVariantMap() ); + auto jobresult = j.exec(); + QVERIFY( jobresult ); + + { + QFile f( tempRoot.filePath( "etc/openswap.conf" ) ); + QVERIFY( f.exists() ); + QVERIFY( f.open( QIODevice::ReadOnly ) ); + cDebug() << f.readAll(); + } +} + +QTEST_GUILESS_MAIN( LOSHTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.conf b/calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.conf new file mode 100644 index 0000000..f1f03bb --- /dev/null +++ b/calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Writes an openswap configuration with LUKS settings to the given path +--- +# Path of the configuration file to write (in the target system) +configFilePath: /etc/openswap.conf diff --git a/calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.schema.yaml b/calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.schema.yaml new file mode 100644 index 0000000..ed2ae79 --- /dev/null +++ b/calamares/src/modules/luksopenswaphookcfg/luksopenswaphookcfg.schema.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/luksopenswaphookcfg +additionalProperties: false +type: object +properties: + configFilePath: { type: string } +required: [ configFilePath ] diff --git a/calamares/src/modules/machineid/CMakeLists.txt b/calamares/src/modules/machineid/CMakeLists.txt new file mode 100644 index 0000000..869d113 --- /dev/null +++ b/calamares/src/modules/machineid/CMakeLists.txt @@ -0,0 +1,15 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(machineid + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + MachineIdJob.cpp + Workers.cpp + SHARED_LIB +) + +calamares_add_test(machineidtest SOURCES Tests.cpp MachineIdJob.cpp Workers.cpp) diff --git a/calamares/src/modules/machineid/MachineIdJob.cpp b/calamares/src/modules/machineid/MachineIdJob.cpp new file mode 100644 index 0000000..df1a52a --- /dev/null +++ b/calamares/src/modules/machineid/MachineIdJob.cpp @@ -0,0 +1,186 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Kevin Kofler + * SPDX-FileCopyrightText: 2016 Philip Müller + * SPDX-FileCopyrightText: 2017 Alf Gaida + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "MachineIdJob.h" +#include "Workers.h" + +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include + +const NamedEnumTable< SystemdMachineIdStyle >& +styleNames() +{ + using T = SystemdMachineIdStyle; + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< SystemdMachineIdStyle > names { + { QStringLiteral( "none" ), T::Blank }, + { QStringLiteral( "blank" ), T::Blank }, + { QStringLiteral( "uuid" ), T::Uuid }, + { QStringLiteral( "systemd" ), T::Uuid }, + { QStringLiteral( "literal-uninitialized" ), T::Uninitialized }, + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +MachineIdJob::MachineIdJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +MachineIdJob::~MachineIdJob() {} + +QString +MachineIdJob::prettyName() const +{ + return tr( "Generate machine-id." ); +} + +Calamares::JobResult +MachineIdJob::exec() +{ + QString root; + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( gs && gs->contains( "rootMountPoint" ) ) + { + root = gs->value( "rootMountPoint" ).toString(); + } + else + { + cWarning() << "No *rootMountPoint* defined."; + return Calamares::JobResult::internalError( tr( "Configuration Error" ), + tr( "No root mount point is set for MachineId." ), + Calamares::JobResult::InvalidConfiguration ); + } + + QString target_systemd_machineid_file = QStringLiteral( "/etc/machine-id" ); + QString target_dbus_machineid_file = QStringLiteral( "/var/lib/dbus/machine-id" ); + + const Calamares::System* system = Calamares::System::instance(); + + // Clear existing files + for ( const auto& entropy_file : m_entropy_files ) + { + system->removeTargetFile( entropy_file ); + } + if ( m_dbus ) + { + system->removeTargetFile( target_dbus_machineid_file ); + } + if ( m_systemd ) + { + system->removeTargetFile( target_systemd_machineid_file ); + } + + //Create new files + for ( const auto& entropy_file : m_entropy_files ) + { + if ( !Calamares::System::instance()->createTargetParentDirs( entropy_file ) ) + { + return Calamares::JobResult::error( + QObject::tr( "Directory not found" ), + QObject::tr( "Could not create new random file
%1
." ).arg( entropy_file ) ); + } + auto r = createEntropy( + m_entropy_copy ? EntropyGeneration::CopyFromHost : EntropyGeneration::New, root, entropy_file ); + if ( !r ) + { + return r; + } + } + if ( m_systemd ) + { + if ( !system->createTargetParentDirs( target_systemd_machineid_file ) ) + { + cWarning() << "Could not create systemd data-directory."; + } + auto r = createSystemdMachineId( m_systemd_style, root, target_systemd_machineid_file ); + if ( !r ) + { + return r; + } + } + if ( m_dbus ) + { + if ( !system->createTargetParentDirs( target_dbus_machineid_file ) ) + { + cWarning() << "Could not create DBus data-directory."; + } + if ( m_dbus_symlink && QFile::exists( root + target_systemd_machineid_file ) ) + { + auto r = createDBusLink( root, target_dbus_machineid_file, target_systemd_machineid_file ); + if ( !r ) + { + return r; + } + } + else + { + auto r = createDBusMachineId( root, target_dbus_machineid_file ); + if ( !r ) + { + return r; + } + } + } + + return Calamares::JobResult::ok(); +} + +void +MachineIdJob::setConfigurationMap( const QVariantMap& map ) +{ + m_systemd = Calamares::getBool( map, "systemd", false ); + + const auto style = Calamares::getString( map, "systemd-style", QString() ); + if ( !style.isEmpty() ) + { + m_systemd_style = styleNames().find( style, SystemdMachineIdStyle::Uuid ); + } + + m_dbus = Calamares::getBool( map, "dbus", false ); + if ( map.contains( "dbus-symlink" ) ) + { + m_dbus_symlink = Calamares::getBool( map, "dbus-symlink", false ); + } + else if ( map.contains( "symlink" ) ) + { + m_dbus_symlink = Calamares::getBool( map, "symlink", false ); + cWarning() << "MachineId: configuration setting *symlink* is deprecated, use *dbus-symlink*."; + } + // else it's still false from the constructor + + // ignore it, though, if dbus is false + m_dbus_symlink = m_dbus && m_dbus_symlink; + + m_entropy_copy = Calamares::getBool( map, "entropy-copy", false ); + m_entropy_files = Calamares::getStringList( map, "entropy-files" ); + if ( Calamares::getBool( map, "entropy", false ) ) + { + cWarning() << " configuration setting *entropy* is deprecated, use *entropy-files* instead."; + m_entropy_files.append( QStringLiteral( "/var/lib/urandom/random-seed" ) ); + } + m_entropy_files.removeDuplicates(); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( MachineIdJobFactory, registerPlugin< MachineIdJob >(); ) diff --git a/calamares/src/modules/machineid/MachineIdJob.h b/calamares/src/modules/machineid/MachineIdJob.h new file mode 100644 index 0000000..1029182 --- /dev/null +++ b/calamares/src/modules/machineid/MachineIdJob.h @@ -0,0 +1,62 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef MACHINEIDJOB_H +#define MACHINEIDJOB_H + +#include "Workers.h" + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include +#include + +/** @brief Write 'random' data: machine id, entropy, UUIDs + * + */ +class PLUGINDLLEXPORT MachineIdJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit MachineIdJob( QObject* parent = nullptr ); + ~MachineIdJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + /** @brief The list of filenames to write full of entropy. + * + * The list may be empty (no entropy files are configure) or + * contain one or more filenames to be interpreted within the + * target system. + */ + QStringList entropyFileNames() const { return m_entropy_files; } + +private: + bool m_systemd = false; ///< write systemd's files + + SystemdMachineIdStyle m_systemd_style = SystemdMachineIdStyle::Uuid; + + bool m_dbus = false; ///< write dbus files + bool m_dbus_symlink = false; ///< .. or just symlink to systemd + + bool m_entropy_copy = false; ///< copy from host system + QStringList m_entropy_files; ///< names of files to write +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( MachineIdJobFactory ) + +#endif // MACHINEIDJOB_H diff --git a/calamares/src/modules/machineid/Tests.cpp b/calamares/src/modules/machineid/Tests.cpp new file mode 100644 index 0000000..9158128 --- /dev/null +++ b/calamares/src/modules/machineid/Tests.cpp @@ -0,0 +1,264 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "MachineIdJob.h" +#include "Workers.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include +#include +#include + +#include + +// Internals of Workers.cpp +extern int getUrandomPoolSize(); + +class MachineIdTests : public QObject +{ + Q_OBJECT +public: + MachineIdTests() {} + ~MachineIdTests() override {} + +private Q_SLOTS: + void initTestCase(); + void testConfigEntropyFiles(); + + void testCopyFile(); + + void testPoolSize(); + + void testJob(); +}; + +void +MachineIdTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +MachineIdTests::testConfigEntropyFiles() +{ + static QString urandom_entropy( "/var/lib/urandom/random-seed" ); + // No config at all + { + QVariantMap m; + MachineIdJob j; + j.setConfigurationMap( m ); + QCOMPARE( j.entropyFileNames(), QStringList() ); + } + // No entropy, deprecated setting + { + QVariantMap m; + MachineIdJob j; + m.insert( "entropy", false ); + j.setConfigurationMap( m ); + QCOMPARE( j.entropyFileNames(), QStringList() ); + } + // Entropy, deprecated setting + { + QVariantMap m; + MachineIdJob j; + m.insert( "entropy", true ); + j.setConfigurationMap( m ); + QCOMPARE( j.entropyFileNames(), QStringList { urandom_entropy } ); + } + // Duplicate entry, with deprecated setting + { + QVariantMap m; + MachineIdJob j; + m.insert( "entropy", true ); + m.insert( "entropy-files", QStringList { urandom_entropy } ); + j.setConfigurationMap( m ); + QCOMPARE( j.entropyFileNames(), QStringList { urandom_entropy } ); + + m.clear(); + j.setConfigurationMap( m ); + QCOMPARE( j.entropyFileNames(), QStringList() ); + + // This would be weird + m.insert( "entropy", false ); + m.insert( "entropy-files", QStringList { urandom_entropy } ); + j.setConfigurationMap( m ); + QCOMPARE( j.entropyFileNames(), QStringList { urandom_entropy } ); + } + // No deprecated setting + { + QString tmp_entropy( "/tmp/entropy" ); + QVariantMap m; + MachineIdJob j; + m.insert( "entropy-files", QStringList { urandom_entropy, tmp_entropy } ); + j.setConfigurationMap( m ); + QVERIFY( !j.entropyFileNames().isEmpty() ); + QCOMPARE( j.entropyFileNames(), QStringList() << urandom_entropy << tmp_entropy ); + } +} + +void +MachineIdTests::testCopyFile() +{ + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-root-XXXXXX" ) ); + cDebug() << "Temporary files as" << QDir::tempPath(); + cDebug() << "Temp dir file at " << tempRoot.path(); + QVERIFY( !tempRoot.path().isEmpty() ); + + // This will pretend to be the host system + QTemporaryDir tempISOdir( QDir::tempPath() + QStringLiteral( "/test-live-XXXXXX" ) ); + QVERIFY( QDir( tempRoot.path() ).mkpath( tempRoot.path() + tempISOdir.path() ) ); + + QFile source( tempRoot.filePath( "example" ) ); + QVERIFY( !source.exists() ); + source.open( QIODevice::WriteOnly ); + source.write( "Derp" ); + source.close(); + QCOMPARE( source.size(), 4 ); + QVERIFY( source.exists() ); + + // This should fail since "example" isn't standard in our test directory + auto r0 = copyFile( tempRoot.path(), "example" ); + QVERIFY( !r0 ); + + const QString sampleFile = QStringLiteral( "CMakeCache.txt" ); + if ( QFile::exists( sampleFile ) ) + { + auto r1 = copyFile( tempRoot.path(), sampleFile ); + // Also fail, because it's not an absolute path + QVERIFY( !r1 ); + + QVERIFY( QFile::copy( sampleFile, tempISOdir.path() + '/' + sampleFile ) ); + auto r2 = copyFile( tempRoot.path(), tempISOdir.path() + '/' + sampleFile ); + QVERIFY( r2 ); + } +} + +void +MachineIdTests::testPoolSize() +{ +#ifdef Q_OS_FREEBSD + // It hardly makes sense, but also the /proc entry is missing + QCOMPARE( getUrandomPoolSize(), 512 ); +#else + // Based on a sample size of 1, Netrunner had 4096. + // Physical HW KDE neon had 256, which gets reported as 512, + // but regular CI builds pass, so leave that special case + // #if-fed out. +#if 0 + KOSRelease r; + if ( r.id() == QStringLiteral( "neon" ) ) + { + QCOMPARE( getUrandomPoolSize(), 512 ); + } + else +#endif + { + QVERIFY( getUrandomPoolSize() >= 512 ); + } +#endif +} + +void +MachineIdTests::testJob() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + QTemporaryDir tempRoot( QDir::tempPath() + QStringLiteral( "/test-job-XXXXXX" ) ); + // Only clean up if the tests succeed + tempRoot.setAutoRemove( false ); + cDebug() << "Temporary files as" << QDir::tempPath(); + + // Ensure we have a system object, expect it to be a "bogus" one + Calamares::System* system = Calamares::System::instance(); + QVERIFY( system ); + QVERIFY( system->doChroot() ); + + // Ensure we have a system-wide GlobalStorage with /tmp as root + if ( !Calamares::JobQueue::instance() ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + } + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); + gs->insert( "rootMountPoint", tempRoot.path() ); + + // Prepare part of the target filesystem + { + QVERIFY( system->createTargetDirs( "/etc" ) ); + auto r = system->createTargetFile( "/etc/machine-id", "Hello" ); + QVERIFY( !r.failed() ); + QVERIFY( r ); + QVERIFY( !r.path().isEmpty() ); + } + + MachineIdJob job( nullptr ); + QVERIFY( !job.prettyName().isEmpty() ); + + QVariantMap config; + config.insert( "dbus", true ); + job.setConfigurationMap( config ); + + { + auto r = job.exec(); + QVERIFY( !r ); // It's supposed to fail, because no dbus-uuidgen executable exists + QVERIFY( QFile::exists( tempRoot.filePath( "var/lib/dbus" ) ) ); // but the target dir exists + } + + config.insert( "dbus-symlink", true ); + job.setConfigurationMap( config ); + { + auto r = job.exec(); + QVERIFY( !r ); // It's supposed to fail, because no dbus-uuidgen executable exists + QVERIFY( QFile::exists( tempRoot.filePath( "var/lib/dbus" ) ) ); // but the target dir exists + + // These all (would) fail, because the chroot isn't viable +#if 0 + QVERIFY( QFile::exists( "/tmp/var/lib/dbus/machine-id" ) ); + + QFileInfo fi( "/tmp/var/lib/dbus/machine-id" ); + QVERIFY( fi.exists() ); + QVERIFY( fi.isSymLink() ); + QCOMPARE( fi.size(), 5 ); +#endif + } + + { + QString tmp_entropy2( "/pineapple.random" ); + QString tmp_entropy( "/tmp/entropy" ); + QVariantMap m; + MachineIdJob j; + m.insert( "entropy-files", QStringList { tmp_entropy2, tmp_entropy } ); + m.insert( "entropy", true ); + j.setConfigurationMap( m ); + QCOMPARE( j.entropyFileNames().count(), 3 ); // Because of the standard entropy entry + + // Check all three are created + auto r = j.exec(); + QVERIFY( r ); + for ( const auto& fileName : j.entropyFileNames() ) + { + cDebug() << "Verifying existence of" << fileName; + QVERIFY( QFile::exists( tempRoot.filePath( fileName.mid( 1 ) ) ) ); + } + } + + tempRoot.setAutoRemove( true ); // All tests succeeded +} + +QTEST_GUILESS_MAIN( MachineIdTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/machineid/Workers.cpp b/calamares/src/modules/machineid/Workers.cpp new file mode 100644 index 0000000..fe86401 --- /dev/null +++ b/calamares/src/modules/machineid/Workers.cpp @@ -0,0 +1,191 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Kevin Kofler + * SPDX-FileCopyrightText: 2016 Philip Müller + * SPDX-FileCopyrightText: 2017 Alf Gaida + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Workers.h" + +#include "MachineIdJob.h" + +#include "utils/Entropy.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include + +/// @brief Returns a recommended size for the entropy pool (in bytes) +STATICTEST int +getUrandomPoolSize() +{ + QFile f( "/proc/sys/kernel/random/poolsize" ); + constexpr const int minimumPoolSize = 512; + int poolSize = minimumPoolSize; + + if ( f.exists() && f.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + QByteArray v = f.read( 16 ); + if ( v.length() > 2 ) + { + if ( v.endsWith( '\n' ) ) + { + v.chop( 1 ); + } + bool ok = false; + poolSize = v.toInt( &ok ); + if ( !ok ) + { + poolSize = minimumPoolSize; + } + } + } + return ( poolSize >= minimumPoolSize ) ? poolSize : minimumPoolSize; +} + +static inline bool +isAbsolutePath( const QString& fileName ) +{ + return fileName.startsWith( '/' ); +} + +Calamares::JobResult +copyFile( const QString& rootMountPoint, const QString& fileName ) +{ + if ( !isAbsolutePath( fileName ) ) + { + return Calamares::JobResult::internalError( + MachineIdJob::tr( "File not found" ), + MachineIdJob::tr( "Path
%1
must be an absolute path." ).arg( fileName ), + 0 ); + } + + QFile f( fileName ); + if ( !f.exists() ) + { + return Calamares::JobResult::error( MachineIdJob::tr( "File not found" ), fileName ); + } + if ( !f.copy( rootMountPoint + fileName ) ) + { + return Calamares::JobResult::error( MachineIdJob::tr( "File not found" ), rootMountPoint + fileName ); + } + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +createNewEntropy( int poolSize, const QString& rootMountPoint, const QString& fileName ) +{ + QFile entropyFile( rootMountPoint + fileName ); + if ( entropyFile.exists() ) + { + cWarning() << "Entropy file" << ( rootMountPoint + fileName ) << "already exists."; + return Calamares::JobResult::ok(); // .. anyway + } + if ( !entropyFile.open( QIODevice::WriteOnly ) ) + { + return Calamares::JobResult::error( + MachineIdJob::tr( "File not found" ), + MachineIdJob::tr( "Could not create new random file
%1
." ).arg( fileName ) ); + } + + QByteArray data; + Calamares::EntropySource source = Calamares::getEntropy( poolSize, data ); + entropyFile.write( data ); + entropyFile.close(); + if ( entropyFile.size() < data.length() ) + { + cWarning() << "Entropy file is" << entropyFile.size() << "bytes, random data was" << data.length(); + } + if ( data.length() < poolSize ) + { + cWarning() << "Entropy data is" << data.length() << "bytes, rather than poolSize" << poolSize; + } + if ( source != Calamares::EntropySource::URandom ) + { + cWarning() << "Entropy data for pool is low-quality."; + } + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +createEntropy( const EntropyGeneration kind, const QString& rootMountPoint, const QString& fileName ) +{ + if ( kind == EntropyGeneration::CopyFromHost ) + { + if ( QFile::exists( fileName ) ) + { + auto r = copyFile( rootMountPoint, fileName ); + if ( r ) + { + return r; + } + else + { + cWarning() << "Could not copy" << fileName << "for entropy, generating new."; + } + } + else + { + cWarning() << "Host system entropy does not exist at" << fileName; + } + } + + int poolSize = getUrandomPoolSize(); + return createNewEntropy( poolSize, rootMountPoint, fileName ); +} + +static Calamares::JobResult +runCmd( const QStringList& cmd, bool inTarget ) +{ + auto r = inTarget ? Calamares::System::instance()->targetEnvCommand( cmd ) + : Calamares::System::instance()->runCommand( cmd, std::chrono::seconds( 0 ) ); + if ( r.getExitCode() ) + { + return r.explainProcess( cmd, std::chrono::seconds( 0 ) ); + } + + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +createSystemdMachineId( SystemdMachineIdStyle style, const QString& rootMountPoint, const QString& machineIdFile ) +{ + switch ( style ) + { + case SystemdMachineIdStyle::Uuid: + return runCmd( + QStringList { QStringLiteral( "systemd-machine-id-setup" ), QStringLiteral( "--root=" ) + rootMountPoint }, + false ); + case SystemdMachineIdStyle::Blank: + Calamares::System::instance()->createTargetFile( + machineIdFile, QByteArray(), Calamares::System::WriteMode::Overwrite ); + return Calamares::JobResult::ok(); + case SystemdMachineIdStyle::Uninitialized: + Calamares::System::instance()->createTargetFile( + machineIdFile, "uninitialized\n", Calamares::System::WriteMode::Overwrite ); + return Calamares::JobResult::ok(); + } + return Calamares::JobResult::internalError( QStringLiteral( "Invalid systemd-style" ), + QStringLiteral( "Invalid value %1" ).arg( int( style ) ), + Calamares::JobResult::InvalidConfiguration ); +} + +Calamares::JobResult +createDBusMachineId( const QString& rootMountPoint, const QString& fileName ) +{ + Q_UNUSED( rootMountPoint ) + Q_UNUSED( fileName ) + return runCmd( QStringList { QStringLiteral( "dbus-uuidgen" ), QStringLiteral( "--ensure" ) }, true ); +} + +Calamares::JobResult +createDBusLink( const QString& rootMountPoint, const QString& fileName, const QString& systemdFileName ) +{ + Q_UNUSED( rootMountPoint ) + return runCmd( QStringList { QStringLiteral( "ln" ), QStringLiteral( "-sf" ), systemdFileName, fileName }, true ); +} diff --git a/calamares/src/modules/machineid/Workers.h b/calamares/src/modules/machineid/Workers.h new file mode 100644 index 0000000..7665a15 --- /dev/null +++ b/calamares/src/modules/machineid/Workers.h @@ -0,0 +1,70 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef MACHINEID_WORKERS_H +#define MACHINEID_WORKERS_H + +#include "Job.h" + +/** @brief Utility functions + * + * These probably belong in libcalamares, since they're general utilities + * for moving files around in the target system. + */ + +/// @brief Copy @p fileName from host into target system at @p rootMountPoint +Calamares::JobResult copyFile( const QString& rootMountPoint, const QString& fileName ); + + +/** @brief Entropy functions + * + * The target system may want to pre-seed the entropy pool with a suitable + * chunk of entropy data. During installation we have lots of disk access + * so plenty of entropy -- this is used mostly be Debian. + */ + +/// @brief How to generate entropy (bool-like) +enum class EntropyGeneration +{ + New, + CopyFromHost +}; + +/// @brief Creates a new entropy file @p fileName in the target system at @p rootMountPoint +Calamares::JobResult createNewEntropy( int poolSize, const QString& rootMountPoint, const QString& fileName ); + +/// @brief Create an entropy file @p fileName in the target system at @p rootMountPoint +Calamares::JobResult +createEntropy( const EntropyGeneration kind, const QString& rootMountPoint, const QString& fileName ); + + +/** @brief MachineID functions + * + * Creating UUIDs for DBUS and SystemD. + */ + + +/// @brief Create a new DBus UUID file +Calamares::JobResult createDBusMachineId( const QString& rootMountPoint, const QString& fileName ); + +/// @brief Symlink DBus UUID file to the one from systemd (which must exist already) +Calamares::JobResult +createDBusLink( const QString& rootMountPoint, const QString& fileName, const QString& systemdFileName ); + +enum class SystemdMachineIdStyle +{ + Uuid, + Blank, + Uninitialized +}; + +Calamares::JobResult +createSystemdMachineId( SystemdMachineIdStyle style, const QString& rootMountPoint, const QString& fileName ); + +#endif // WORKERS_H diff --git a/calamares/src/modules/machineid/machineid.conf b/calamares/src/modules/machineid/machineid.conf new file mode 100644 index 0000000..6a45234 --- /dev/null +++ b/calamares/src/modules/machineid/machineid.conf @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Machine-ID and other random data on the target system. +# +# This module can create a number of "random" things on the target: +# - a systemd machine-id file (hence the name of the Calamares module) +# with a random UUID. +# - a dbus machine-id file (or, optionally, link to the one from systemd) +# - an entropy file +# +--- +# Whether to create /etc/machine-id for systemd. +# The default is *false*. +systemd: true +# If systemd is true, the kind of /etc/machine-id to create in the target +# - uuid (default) generates a UUID +# - systemd alias of uuid +# - blank creates the file but leaves it empty at 0 bytes +# - none alias of blank (use `systemd: false` if you don't want one at all) +# - literal-uninitialized creates the file and writes the string "uninitialized\n" +systemd-style: uuid + +# Whether to create /var/lib/dbus/machine-id for D-Bus. +# The default is *false*. +dbus: true +# Whether /var/lib/dbus/machine-id should be a symlink to /etc/machine-id +# (ignored if dbus is false, or if there is no /etc/machine-id to point to). +# The default is *false*. +dbus-symlink: true + +# Copy entropy from the host? If this is set to *true*, then +# any entropy file listed below will be copied from the host +# if it exists. Non-existent files will be generated from +# /dev/urandom . The default is *false*. +entropy-copy: false +# Which files to write (paths in the target). Each of these files is +# either generated from /dev/urandom or copied from the host, depending +# on the setting for *entropy-copy*, above. +entropy-files: + - /var/lib/urandom/random-seed + - /var/lib/systemd/random-seed + +# Whether to create an entropy file /var/lib/urandom/random-seed +# +# DEPRECATED: list the file in entropy-files instead. If this key +# exists and is set to *true*, a warning is printed and Calamares +# behaves as if `/var/lib/urandom/random-seed` is listed in *entropy-files*. +# +# entropy: false + +# Whether to create a symlink for D-Bus +# +# DEPRECATED: set *dbus-symlink* with the same meaning instead. +# +# symlink: false diff --git a/calamares/src/modules/machineid/machineid.schema.yaml b/calamares/src/modules/machineid/machineid.schema.yaml new file mode 100644 index 0000000..f56b390 --- /dev/null +++ b/calamares/src/modules/machineid/machineid.schema.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/machineid +additionalProperties: false +type: object +properties: + systemd: { type: boolean, default: false } + "systemd-style": { type: string, enum: [ uuid, blank, literal-uninitialized ] } + dbus: { type: boolean, default: false } + "dbus-symlink": { type: boolean, default: false } + "entropy-copy": { type: boolean, default: false } + "entropy-files": { type: array, items: { type: string } } + # Deprecated properties + symlink: { type: boolean, default: false } + entropy: { type: boolean, default: false } diff --git a/calamares/src/modules/mkinitfs/main.py b/calamares/src/modules/mkinitfs/main.py new file mode 100644 index 0000000..e3e6e17 --- /dev/null +++ b/calamares/src/modules/mkinitfs/main.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014-2015 Philip Müller +# SPDX-FileCopyrightText: 2014 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import libcalamares +from libcalamares.utils import target_env_call + + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Creating initramfs with mkinitfs.") + + +def run_mkinitfs(): + """ + Creates initramfs, even when initramfs already exists. + + :return: + """ + return target_env_call(['mkinitfs']) + + +def run(): + """ + Starts routine to create initramfs. It passes back the exit code + if it fails. + + :return: + """ + return_code = run_mkinitfs() + + if return_code != 0: + return ( _("Failed to run mkinitfs on the target"), + _("The exit code was {}").format(return_code) ) diff --git a/calamares/src/modules/mkinitfs/module.desc b/calamares/src/modules/mkinitfs/module.desc new file mode 100644 index 0000000..decc325 --- /dev/null +++ b/calamares/src/modules/mkinitfs/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "mkinitfs" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/mount/main.py b/calamares/src/modules/mount/main.py new file mode 100644 index 0000000..4a16f88 --- /dev/null +++ b/calamares/src/modules/mount/main.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Aurélien Gâteau +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-FileCopyrightText: 2019 Kevin Kofler +# SPDX-FileCopyrightText: 2019-2020 Collabora Ltd +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import tempfile +import subprocess +import os +import re + +import libcalamares + +import gettext + +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +class ZfsException(Exception): + """Exception raised when there is a problem with zfs + + Attributes: + message -- explanation of the error + """ + + def __init__(self, message): + self.message = message + + +def pretty_name(): + return _("Mounting partitions.") + + +def disk_name_for_partition(partition): + """ Returns disk name for each found partition. + + :param partition: + :return: + """ + name = os.path.basename(partition["device"]) + + if name.startswith("/dev/mmcblk") or name.startswith("/dev/nvme"): + return re.sub("p[0-9]+$", "", name) + + return re.sub("[0-9]+$", "", name) + + +def is_ssd_disk(partition): + """ Checks if given partition is on an ssd disk. + + :param partition: A dict containing the partition information + :return: True is the partition in on an ssd, False otherwise + """ + + try: + disk_name = disk_name_for_partition(partition) + filename = os.path.join("/sys/block", disk_name, "queue/rotational") + + with open(filename) as sysfile: + return sysfile.read() == "0\n" + except: + return False + + +def get_mount_options(filesystem, mount_options, partition, efi_location = None): + """ + Returns the mount options for the partition object and filesystem + + :param filesystem: A string containing the filesystem + :param mount_options: A list of dicts that descripes the mount options for each mountpoint + :param partition: A dict containing information about the partition + :param efi_location: A string holding the location of the EFI partition or None + :return: A comma seperated string containing the mount options suitable for passing to mount + """ + + # Extra mounts can optionally have "options" set, in this case, they override other all other settings + if "options" in partition: + return ",".join(partition["options"]) + + # If there are no mount options defined then we use the defaults + if mount_options is None: + return "defaults" + + # The EFI partition uses special mounting options + if efi_location and partition["mountPoint"] == efi_location: + effective_filesystem = "efi" + else: + effective_filesystem = filesystem + + options = next((x for x in mount_options if x["filesystem"] == effective_filesystem), None) + + # If there is no match then check for default options + if options is None: + options = next((x for x in mount_options if x["filesystem"] == "default"), None) + + # If it is still None, then fallback to returning defaults + if options is None: + return "defaults" + + option_items = options.get("options", []).copy() + + # Append the appropriate options for ssd or hdd if set + if is_ssd_disk(partition): + option_items.extend(options.get("ssdOptions", [])) + else: + option_items.extend(options.get("hddOptions", [])) + + if option_items: + return ",".join(option_items) + else: + return "defaults" + + +def get_btrfs_subvolumes(partitions): + """ + Gets the job-configuration for btrfs subvolumes, or if there is + none given, returns a default configuration that matches + the setup (/ and /home) from before configurability was introduced. + + @param partitions + The partitions (from the partitioning module) that will exist on disk. + This is used to filter out subvolumes that don't need to be created + because they get a dedicated partition instead. + """ + btrfs_subvolumes = libcalamares.job.configuration.get("btrfsSubvolumes", None) + # Warn if there's no configuration at all, and empty configurations are + # replaced by a simple root-only layout. + if btrfs_subvolumes is None: + libcalamares.utils.warning("No configuration for btrfsSubvolumes") + if not btrfs_subvolumes: + btrfs_subvolumes = [dict(mountPoint="/", subvolume="/@"), dict(mountPoint="/home", subvolume="/@home")] + + # Filter out the subvolumes which have a dedicated partition + non_root_partition_mounts = [m for m in [p.get("mountPoint", None) for p in partitions] if + m is not None and m != '/'] + btrfs_subvolumes = list(filter(lambda s: s["mountPoint"] not in non_root_partition_mounts, btrfs_subvolumes)) + + # If we have a swap **file**, give it a separate subvolume. + swap_choice = libcalamares.globalstorage.value("partitionChoices") + if swap_choice and swap_choice.get("swap", None) == "file": + swap_subvol = libcalamares.job.configuration.get("btrfsSwapSubvol", "/@swap") + btrfs_subvolumes.append({'mountPoint': '/swap', 'subvolume': swap_subvol}) + libcalamares.globalstorage.insert("btrfsSwapSubvol", swap_subvol) + + return btrfs_subvolumes + + +def mount_zfs(root_mount_point, partition): + """ Mounts a zfs partition at @p root_mount_point + + :param root_mount_point: The absolute path to the root of the install + :param partition: The partition map from global storage for this partition + :return: + """ + # Get the list of zpools from global storage + zfs_pool_list = libcalamares.globalstorage.value("zfsPoolInfo") + if not zfs_pool_list: + libcalamares.utils.warning("Failed to locate zfsPoolInfo data in global storage") + raise ZfsException(_("Internal error mounting zfs datasets")) + + # Find the zpool matching this partition + for zfs_pool in zfs_pool_list: + if zfs_pool["mountpoint"] == partition["mountPoint"]: + pool_name = zfs_pool["poolName"] + ds_name = zfs_pool["dsName"] + + # import the zpool + try: + libcalamares.utils.host_env_process_output(["zpool", "import", "-N", "-R", root_mount_point, pool_name], None) + except subprocess.CalledProcessError: + raise ZfsException(_("Failed to import zpool")) + + # Get the encrpytion information from global storage + zfs_info_list = libcalamares.globalstorage.value("zfsInfo") + encrypt = False + if zfs_info_list: + for zfs_info in zfs_info_list: + if zfs_info["mountpoint"] == partition["mountPoint"] and zfs_info["encrypted"] is True: + encrypt = True + passphrase = zfs_info["passphrase"] + + if encrypt is True: + # The zpool is encrypted, we need to unlock it + try: + libcalamares.utils.host_env_process_output(["zfs", "load-key", pool_name], None, passphrase) + except subprocess.CalledProcessError: + raise ZfsException(_("Failed to unlock zpool")) + + if partition["mountPoint"] == '/': + # Get the zfs dataset list from global storage + zfs = libcalamares.globalstorage.value("zfsDatasets") + + if not zfs: + libcalamares.utils.warning("Failed to locate zfs dataset list") + raise ZfsException(_("Internal error mounting zfs datasets")) + + zfs.sort(key=lambda x: x["mountpoint"]) + for dataset in zfs: + try: + if dataset["canMount"] == "noauto" or dataset["canMount"] is True: + libcalamares.utils.host_env_process_output(["zfs", "mount", + dataset["zpool"] + '/' + dataset["dsName"]]) + except subprocess.CalledProcessError: + raise ZfsException(_("Failed to set zfs mountpoint")) + else: + try: + libcalamares.utils.host_env_process_output(["zfs", "mount", pool_name + '/' + ds_name]) + except subprocess.CalledProcessError: + raise ZfsException(_("Failed to set zfs mountpoint")) + + +def mount_partition(root_mount_point, partition, partitions, mount_options, mount_options_list, efi_location): + """ + Do a single mount of @p partition inside @p root_mount_point. + + :param root_mount_point: A string containing the root of the install + :param partition: A dict containing information about the partition + :param partitions: The full list of partitions used to filter out btrfs subvols which have duplicate mountpoints + :param mount_options: The mount options from the config file + :param mount_options_list: A list of options for each mountpoint to be placed in global storage for future modules + :param efi_location: A string holding the location of the EFI partition or None + :return: + """ + # Create mount point with `+` rather than `os.path.join()` because + # `partition["mountPoint"]` starts with a '/'. + raw_mount_point = partition["mountPoint"] + if not raw_mount_point: + return + + mount_point = root_mount_point + raw_mount_point + + # Ensure that the created directory has the correct SELinux context on + # SELinux-enabled systems. + + os.makedirs(mount_point, exist_ok=True) + + try: + subprocess.call(['chcon', '--reference=' + raw_mount_point, mount_point]) + except FileNotFoundError as e: + libcalamares.utils.warning(str(e)) + except OSError: + libcalamares.utils.error("Cannot run 'chcon' normally.") + raise + + fstype = partition.get("fs", "").lower() + if fstype == "unformatted": + return + + if fstype == "fat16" or fstype == "fat32": + fstype = "vfat" + + device = partition["device"] + + if "luksMapperName" in partition: + device = os.path.join("/dev/mapper", partition["luksMapperName"]) + + if fstype == "zfs": + mount_zfs(root_mount_point, partition) + else: # fstype == "zfs" + mount_options_string = get_mount_options(fstype, mount_options, partition, efi_location) + if libcalamares.utils.mount(device, + mount_point, + fstype, + mount_options_string) != 0: + libcalamares.utils.warning("Cannot mount {}".format(device)) + mount_options_list.append({"mountpoint": raw_mount_point, "option_string": mount_options_string}) + + # Special handling for btrfs subvolumes. Create the subvolumes listed in mount.conf + if fstype == "btrfs" and partition["mountPoint"] == '/': + # Root has been mounted to btrfs volume -> create subvolumes from configuration + btrfs_subvolumes = get_btrfs_subvolumes(partitions) + + # Store created list in global storage so it can be used in the fstab module + libcalamares.globalstorage.insert("btrfsSubvolumes", btrfs_subvolumes) + # Create the subvolumes that are in the completed list + for s in btrfs_subvolumes: + if not s["subvolume"]: + continue + os.makedirs(root_mount_point + os.path.dirname(s["subvolume"]), exist_ok=True) + subprocess.check_call(["btrfs", "subvolume", "create", + root_mount_point + s["subvolume"]]) + if s["mountPoint"] == "/": + # insert the root subvolume into global storage + libcalamares.globalstorage.insert("btrfsRootSubvolume", s["subvolume"]) + subprocess.check_call(["umount", "-v", root_mount_point]) + + device = partition["device"] + + if "luksMapperName" in partition: + device = os.path.join("/dev/mapper", partition["luksMapperName"]) + + # Mount the subvolumes + swap_subvol = libcalamares.job.configuration.get("btrfsSwapSubvol", "/@swap") + for s in btrfs_subvolumes: + if s['subvolume'] == swap_subvol: + mount_option_no_subvol = get_mount_options("btrfs_swap", mount_options, partition) + else: + mount_option_no_subvol = get_mount_options(fstype, mount_options, partition) + + # Only add subvol= argument if we are not mounting the entire filesystem + if s['subvolume']: + mount_option = f"subvol={s['subvolume']},{mount_option_no_subvol}" + else: + mount_option = mount_option_no_subvol + subvolume_mountpoint = mount_point[:-1] + s['mountPoint'] + mount_options_list.append({"mountpoint": s['mountPoint'], "option_string": mount_option_no_subvol}) + if libcalamares.utils.mount(device, + subvolume_mountpoint, + fstype, + mount_option) != 0: + libcalamares.utils.warning("Cannot mount {}".format(device)) + + +def enable_swap_partition(devices): + try: + for d in devices: + libcalamares.utils.host_env_process_output(["swapon", d]) + except subprocess.CalledProcessError: + libcalamares.utils.warning(f"Failed to enable swap for devices: {devices}") + + +def run(): + """ + Mount all the partitions from GlobalStorage and from the job configuration. + Partitions are mounted in-lexical-order of their mountPoint. + """ + + partitions = libcalamares.globalstorage.value("partitions") + + if not partitions: + libcalamares.utils.warning("partitions is empty, {!s}".format(partitions)) + return (_("Configuration Error"), + _("No partitions are defined for
{!s}
to use.").format("mount")) + + # Find existing swap partitions that are part of the installation and enable them now + claimed_swap_partitions = [p for p in partitions if p["fs"] == "linuxswap" and p.get("claimed", False)] + plain_swap = [p for p in claimed_swap_partitions if p["fsName"] == "linuxswap"] + luks_swap = [p for p in claimed_swap_partitions if p["fsName"] == "luks" or p["fsName"] == "luks2"] + swap_devices = [p["device"] for p in plain_swap] + ["/dev/mapper/" + p["luksMapperName"] for p in luks_swap] + + enable_swap_partition(swap_devices) + + root_mount_point = tempfile.mkdtemp(prefix="calamares-root-") + + # Get the mountOptions, if this is None, that is OK and will be handled later + mount_options = libcalamares.job.configuration.get("mountOptions") + + # Guard against missing keys (generally a sign that the config file is bad) + extra_mounts = libcalamares.job.configuration.get("extraMounts") or [] + if not extra_mounts: + libcalamares.utils.warning("No extra mounts defined. Does mount.conf exist?") + + efi_location = None + if libcalamares.globalstorage.value("firmwareType") == "efi": + efi_location = libcalamares.globalstorage.value("efiSystemPartition") + else: + for mount in extra_mounts: + if mount.get("efi", None) is True: + extra_mounts.remove(mount) + + # Add extra mounts to the partitions list and sort by mount points. + # This way, we ensure / is mounted before the rest, and every mount point + # is created on the right partition (e.g. if a partition is to be mounted + # under /tmp, we make sure /tmp is mounted before the partition) + mountable_partitions = [p for p in partitions + extra_mounts if "mountPoint" in p and p["mountPoint"]] + mountable_partitions.sort(key=lambda x: x["mountPoint"]) + + # mount_options_list will be inserted into global storage for use in fstab later + mount_options_list = [] + try: + for partition in mountable_partitions: + mount_partition(root_mount_point, partition, partitions, mount_options, mount_options_list, efi_location) + except ZfsException as ze: + return _("zfs mounting error"), ze.message + + if not mount_options_list: + libcalamares.utils.warning("No mount options defined, {!s} partitions, {!s} mountable".format(len(partitions), len(mountable_partitions))) + + libcalamares.globalstorage.insert("rootMountPoint", root_mount_point) + libcalamares.globalstorage.insert("mountOptionsList", mount_options_list) + + # Remember the extra mounts for the unpackfs module + libcalamares.globalstorage.insert("extraMounts", extra_mounts) diff --git a/calamares/src/modules/mount/module.desc b/calamares/src/modules/mount/module.desc new file mode 100644 index 0000000..e4486cf --- /dev/null +++ b/calamares/src/modules/mount/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "mount" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/mount/mount.conf b/calamares/src/modules/mount/mount.conf new file mode 100644 index 0000000..da95395 --- /dev/null +++ b/calamares/src/modules/mount/mount.conf @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Mount filesystems in the target (generally, before treating the +# target as a usable chroot / "live" system). Filesystems are +# automatically mounted from the partitioning module. Filesystems +# listed here are **extra**. The filesystems listed in *extraMounts* +# are mounted in all target systems. +--- +# Extra filesystems to mount. The key's value is a list of entries; each +# entry has five keys: +# - device The device node to mount +# - fs (optional) The filesystem type to use +# - mountPoint Where to mount the filesystem +# - options (optional) An array of options to pass to mount +# - efi (optional) A boolean that when true is only mounted for UEFI installs +# +# The device is not mounted if the mountPoint is unset or if the fs is +# set to unformatted. +# +extraMounts: + - device: proc + fs: proc + mountPoint: /proc + - device: sys + fs: sysfs + mountPoint: /sys + - device: /dev + mountPoint: /dev + options: [ bind ] + - device: tmpfs + fs: tmpfs + mountPoint: /run + - device: /run/udev + mountPoint: /run/udev + options: [ bind ] + - device: efivarfs + fs: efivarfs + mountPoint: /sys/firmware/efi/efivars + efi: true + +# Btrfs subvolumes to create if root filesystem is on btrfs volume. +# If *mountpoint* is mounted already to another partition, it is ignored. +# Separate subvolume for swapfile is handled separately and automatically. +# +# It is possible to prevent subvolume creation -- this is likely only relevant +# for the root (/) subvolume -- by giving an empty string as a subvolume +# name. In this case no subvolume will be created. +# +btrfsSubvolumes: + - mountPoint: / + subvolume: /@ + # As an alternative: + # + # subvolume: "" + - mountPoint: /home + subvolume: /@home + - mountPoint: /var/cache + subvolume: /@cache + - mountPoint: /var/log + subvolume: /@log + +# The name of the btrfs subvolume holding the swapfile. This only used when +# a swapfile is selected and the root filesystem is btrfs +# +btrfsSwapSubvol: /@swap + +# The mount options used to mount each filesystem. +# +# filesystem contains the name of the filesystem or on of three special +# values, "default", efi" and "btrfs_swap". The logic is applied in this manner: +# - If the partition is the EFI partition, the "efi" entry will be used +# - If the fs is btrfs and the subvolume is for the swapfile, +# the "btrfs_swap" entry is used +# - If the filesystem is an exact match for filesystem, that entry is used +# - If no match is found in the above, the default entry is used +# - If there is no match and no default entry, "defaults" is used +# - If the mountOptions key is not present, "defaults" is used +# +# Each filesystem entry contains 3 keys, all of which are optional +# options - An array of mount options that is used on all disk types +# ssdOptions - An array of mount options combined with options for ssds +# hddOptions - An array of mount options combined with options for hdds +# If combining these options results in an empty array, "defaults" is used +# +# Example 1 +# In this example, there are specific options for ext4 and btrfs filesystems, +# the EFI partition and the subvolume holding the btrfs swapfile. All other +# filesystems use the default entry. For the btrfs filesystem, there are +# additional options specific to hdds and ssds +# +# mountOptions: +# - filesystem: default +# options: [ defaults ] +# - filesystem: efi +# options: [ defaults, umask=0077 ] +# - filesystem: ext4 +# options: [ defaults ] +# - filesystem: btrfs +# options: [ defaults, compress=zstd:1 ] +# ssdOptions: [ discard=async ] +# hddOptions: [ autodefrag ] +# - filesystem: btrfs_swap +# options: [ defaults, noatime ] +# +# Example 2 +# In this example there is a single default used by all filesystems +# +# mountOptions: +# - filesystem: default +# options: [ defaults ] +# +mountOptions: + - filesystem: default + options: [ defaults ] + - filesystem: efi + options: [ defaults, umask=0077 ] + - filesystem: btrfs + options: [ defaults, compress=zstd:1 ] + - filesystem: btrfs_swap + options: [ defaults, noatime ] + + + + diff --git a/calamares/src/modules/mount/mount.schema.yaml b/calamares/src/modules/mount/mount.schema.yaml new file mode 100644 index 0000000..a083ed7 --- /dev/null +++ b/calamares/src/modules/mount/mount.schema.yaml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/mount +additionalProperties: false +type: object +properties: + extraMounts: + type: array + items: + type: object + additionalProperties: false + properties: + device: { type: string } + fs: { type: string } + mountPoint: { type: string } + options: { type: array, items: { type: string } } + efi: { type: boolean, default: false } + required: [ device, mountPoint ] + btrfsSubvolumes: + type: array + items: + type: object + additionalProperties: false + properties: + mountPoint: { type: string } + subvolume: { type: string } + required: [ subvolume, mountPoint ] + btrfsSwapSubvol: { type: string } + mountOptions: + type: array + items: + type: object + additionalProperties: false + properties: + filesystem: { type: string } + options: { type: array, items: { type: string } } + ssdOptions: { type: array, items: { type: string } } + hddOptions: { type: array, items: { type: string } } + required: [ filesystem ] + + diff --git a/calamares/src/modules/mount/tests/1.global b/calamares/src/modules/mount/tests/1.global new file mode 100644 index 0000000..ce3e12e --- /dev/null +++ b/calamares/src/modules/mount/tests/1.global @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +partitions: + - device: "/dev/sdb1" + mountPoint: "/" + fs: "ext4" + - device: "/dev/sdb2" + mountPoint: "/home" + fs: "ext4" + - device: "/dev/sdb3" + mountPoint: "" + fs: "linuxswap" diff --git a/calamares/src/modules/mount/tests/1.job b/calamares/src/modules/mount/tests/1.job new file mode 100644 index 0000000..5d2365c --- /dev/null +++ b/calamares/src/modules/mount/tests/1.job @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +btrfsSwapSubvol: "" + +# No configuration needed because the partitions are +# all filesystems that require no special handling. diff --git a/calamares/src/modules/mount/tests/2.global b/calamares/src/modules/mount/tests/2.global new file mode 100644 index 0000000..20aba89 --- /dev/null +++ b/calamares/src/modules/mount/tests/2.global @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +partitions: + - device: "/dev/sdb1" + mountPoint: "/" + fs: "btrfs" + +# Expect a complaint and a default btrfs layout diff --git a/calamares/src/modules/mount/tests/2.job b/calamares/src/modules/mount/tests/2.job new file mode 100644 index 0000000..54ff59d --- /dev/null +++ b/calamares/src/modules/mount/tests/2.job @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +btrfsSwapSubvol: "" + +# Expect a complaint and a default btrfs layout because the +# partitions use btrfs diff --git a/calamares/src/modules/mount/tests/3.global b/calamares/src/modules/mount/tests/3.global new file mode 100644 index 0000000..9dae421 --- /dev/null +++ b/calamares/src/modules/mount/tests/3.global @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +partitions: + - device: "/dev/sdb1" + mountPoint: "/" + fs: "btrfs" +partitionChoices: + swap: file diff --git a/calamares/src/modules/mount/tests/3.job b/calamares/src/modules/mount/tests/3.job new file mode 100644 index 0000000..5d2365c --- /dev/null +++ b/calamares/src/modules/mount/tests/3.job @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +btrfsSwapSubvol: "" + +# No configuration needed because the partitions are +# all filesystems that require no special handling. diff --git a/calamares/src/modules/mount/tests/4.global b/calamares/src/modules/mount/tests/4.global new file mode 100644 index 0000000..1856c9d --- /dev/null +++ b/calamares/src/modules/mount/tests/4.global @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +partitions: + - device: "/dev/sdb1" + mountPoint: "/" + fs: "btrfs" + - device: "/dev/sdb2" + mountPoint: "/home" + fs: "ext4" +partitionChoices: + swap: file diff --git a/calamares/src/modules/mount/tests/4.job b/calamares/src/modules/mount/tests/4.job new file mode 100644 index 0000000..dac7582 --- /dev/null +++ b/calamares/src/modules/mount/tests/4.job @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 + +btrfsSubvolumes: + - mountPoint: / + subvolume: /@ + - mountPoint: /home + subvolume: /@home + - mountPoint: /var/cache + subvolume: /@cache + - mountPoint: /var/log + subvolume: /@log diff --git a/calamares/src/modules/netinstall/CMakeLists.txt b/calamares/src/modules/netinstall/CMakeLists.txt new file mode 100644 index 0000000..3000ebf --- /dev/null +++ b/calamares/src/modules/netinstall/CMakeLists.txt @@ -0,0 +1,30 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(netinstall + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + groupstreeview.cpp + LoaderQueue.cpp + NetInstallViewStep.cpp + NetInstallPage.cpp + PackageTreeItem.cpp + PackageModel.cpp + UI + page_netinst.ui + LINK_PRIVATE_LIBRARIES + ${qtname}::Network + SHARED_LIB +) + +if(TARGET ${kfname}::CoreAddons) + calamares_add_test( + netinstalltest + SOURCES Tests.cpp Config.cpp LoaderQueue.cpp PackageTreeItem.cpp PackageModel.cpp + LIBRARIES ${qtname}::Gui ${qtname}::Network ${kfname}::CoreAddons + ) +endif() diff --git a/calamares/src/modules/netinstall/Config.cpp b/calamares/src/modules/netinstall/Config.cpp new file mode 100644 index 0000000..36b357c --- /dev/null +++ b/calamares/src/modules/netinstall/Config.cpp @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-FileCopyrightText: 2017 Gabriel Craciunescu + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "LoaderQueue.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" +#include "network/Manager.h" +#include "packages/Globals.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/Variant.h" + +#include + +Config::Config( QObject* parent ) + : QObject( parent ) + , m_model( new PackageModel( this ) ) +{ + CALAMARES_RETRANSLATE_SLOT( &Config::retranslate ); +} + +Config::~Config() {} + +void +Config::retranslate() +{ + emit statusChanged( status() ); + emit sidebarLabelChanged( sidebarLabel() ); + emit titleLabelChanged( titleLabel() ); +} + +QString +Config::status() const +{ + switch ( m_status ) + { + case Status::Ok: + return QString(); + case Status::FailedBadConfiguration: + return tr( "Network Installation. (Disabled: Incorrect configuration)" ); + case Status::FailedBadData: + return tr( "Network Installation. (Disabled: Received invalid groups data)" ); + case Status::FailedInternalError: + return tr( "Network Installation. (Disabled: Internal error)" ); + case Status::FailedNetworkError: + return tr( "Network Installation. (Disabled: Unable to fetch package lists, check your network connection)" ); + case Status::FailedNoData: + return tr( "Network Installation. (Disabled: No package list)" ); + } + __builtin_unreachable(); +} + +void +Config::setStatus( Status s ) +{ + m_status = s; + emit statusChanged( status() ); +} + +QString +Config::sidebarLabel() const +{ + return m_sidebarLabel ? m_sidebarLabel->get() : tr( "Package selection" ); +} + +QString +Config::titleLabel() const +{ + return m_titleLabel ? m_titleLabel->get() : QString(); +} + +void +Config::loadGroupList( const QVariantList& groupData ) +{ + m_model->setupModelData( groupData ); + if ( m_model->rowCount() < 1 ) + { + cWarning() << "NetInstall groups data was empty."; + setStatus( Status::FailedNoData ); + } + else + { + setStatus( Status::Ok ); + } +} + +void +Config::loadingDone() +{ + if ( m_queue ) + { + m_queue->deleteLater(); + m_queue = nullptr; + } + emit statusReady(); +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + setRequired( Calamares::getBool( configurationMap, "required", false ) ); + + // Get the translations, if any + bool bogus = false; + auto label = Calamares::getSubMap( configurationMap, "label", bogus ); + // Use a different class name for translation lookup because the + // .. table of strings lives in NetInstallViewStep.cpp and moving them + // .. around is annoying for translators. + static const char className[] = "NetInstallViewStep"; + + if ( label.contains( "sidebar" ) ) + { + m_sidebarLabel = new Calamares::Locale::TranslatedString( label, "sidebar", className ); + } + if ( label.contains( "title" ) ) + { + m_titleLabel = new Calamares::Locale::TranslatedString( label, "title", className ); + } + + // Lastly, load the groups data + const QString key = QStringLiteral( "groupsUrl" ); + const auto& groupsUrlVariant = configurationMap.value( key ); + m_queue = new LoaderQueue( this ); + if ( Calamares::typeOf( groupsUrlVariant ) == Calamares::StringVariantType ) + { + m_queue->append( SourceItem::makeSourceItem( groupsUrlVariant.toString(), configurationMap ) ); + } + else if ( Calamares::typeOf( groupsUrlVariant ) == Calamares::ListVariantType ) + { + for ( const auto& s : groupsUrlVariant.toStringList() ) + { + m_queue->append( SourceItem::makeSourceItem( s, configurationMap ) ); + } + } + + setStatus( required() ? Status::FailedNoData : Status::Ok ); + cDebug() << "Loading netinstall from" << m_queue->count() << "alternate sources."; + connect( m_queue, &LoaderQueue::done, this, &Config::loadingDone ); + m_queue->load(); +} + +void +Config::finalizeGlobalStorage( const Calamares::ModuleSystem::InstanceKey& key ) +{ + auto packages = model()->getPackages(); + + // This netinstall module may add two sub-steps to the packageOperations, + // one for installing and one for try-installing. + QVariantList installPackages; + QVariantList tryInstallPackages; + + for ( const auto& package : packages ) + { + if ( package->isCritical() ) + { + installPackages.append( package->toOperation() ); + } + else + { + tryInstallPackages.append( package->toOperation() ); + } + } + + Calamares::Packages::setGSPackageAdditions( + Calamares::JobQueue::instance()->globalStorage(), key, installPackages, tryInstallPackages ); +} diff --git a/calamares/src/modules/netinstall/Config.h b/calamares/src/modules/netinstall/Config.h new file mode 100644 index 0000000..85c3e6b --- /dev/null +++ b/calamares/src/modules/netinstall/Config.h @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef NETINSTALL_CONFIG_H +#define NETINSTALL_CONFIG_H + +#include "PackageModel.h" + +#include "locale/TranslatableConfiguration.h" +#include "modulesystem/InstanceKey.h" + +#include +#include + +#include + +class LoaderQueue; + +class Config : public QObject +{ + Q_OBJECT + + Q_PROPERTY( PackageModel* packageModel MEMBER m_model FINAL ) + Q_PROPERTY( QString status READ status NOTIFY statusChanged FINAL ) + + // Translations, of the module name (for sidebar) and above the list + Q_PROPERTY( QString sidebarLabel READ sidebarLabel NOTIFY sidebarLabelChanged FINAL ) + Q_PROPERTY( QString titleLabel READ titleLabel NOTIFY titleLabelChanged FINAL ) + +public: + Config( QObject* parent = nullptr ); + ~Config() override; + + void setConfigurationMap( const QVariantMap& configurationMap ); + + enum class Status + { + Ok, + FailedBadConfiguration, + FailedInternalError, + FailedNetworkError, + FailedBadData, + FailedNoData + }; + + /// Human-readable, translated representation of the status + QString status() const; + /// Internal code for the status + Status statusCode() const { return m_status; } + void setStatus( Status s ); + + bool required() const { return m_required; } + void setRequired( bool r ) { m_required = r; } + + PackageModel* model() const { return m_model; } + + QString sidebarLabel() const; + QString titleLabel() const; + + /** @brief Fill model from parsed data. + * + * Fills the model with a list of groups -- which can contain + * subgroups and packages -- from @p groupData. + */ + void loadGroupList( const QVariantList& groupData ); + + /** @brief Write the selected package lists to global storage + * + * Since the config doesn't know what module it is for, + * pass in an instance key. + */ + void finalizeGlobalStorage( const Calamares::ModuleSystem::InstanceKey& key ); + +Q_SIGNALS: + void statusChanged( QString status ); ///< Something changed + void sidebarLabelChanged( QString label ); + void titleLabelChanged( QString label ); + void statusReady(); ///< Loading groups is complete + +private Q_SLOTS: + void retranslate(); + void loadingDone(); + +private: + Calamares::Locale::TranslatedString* m_sidebarLabel = nullptr; // As it appears in the sidebar + Calamares::Locale::TranslatedString* m_titleLabel = nullptr; + PackageModel* m_model = nullptr; + LoaderQueue* m_queue = nullptr; + Status m_status = Status::Ok; + bool m_required = false; +}; + +#endif diff --git a/calamares/src/modules/netinstall/LoaderQueue.cpp b/calamares/src/modules/netinstall/LoaderQueue.cpp new file mode 100644 index 0000000..07ce0ed --- /dev/null +++ b/calamares/src/modules/netinstall/LoaderQueue.cpp @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "LoaderQueue.h" + +#include "Config.h" +#include "network/Manager.h" +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/Yaml.h" + +#include +#include + +/** @brief Call fetchNext() on the queue if it can + * + * On destruction, a new call to fetchNext() is queued, so that + * the queue continues loading. Calling release() before the + * destructor skips the fetchNext(), ending the queue-loading. + * + * Calling done(b) is a conditional release: if @p b is @c true, + * queues a call to done() on the queue and releases it; otherwise, + * does nothing. + */ +class FetchNextUnless +{ +public: + FetchNextUnless( LoaderQueue* q ) + : m_q( q ) + { + } + ~FetchNextUnless() + { + if ( m_q ) + { + QMetaObject::invokeMethod( m_q, "fetchNext", Qt::QueuedConnection ); + } + } + void release() { m_q = nullptr; } + void done( bool b ) + { + if ( b ) + { + if ( m_q ) + { + QMetaObject::invokeMethod( m_q, "done", Qt::QueuedConnection ); + } + release(); + } + } + +private: + LoaderQueue* m_q = nullptr; +}; + +SourceItem +SourceItem::makeSourceItem( const QString& groupsUrl, const QVariantMap& configurationMap ) +{ + if ( groupsUrl == QStringLiteral( "local" ) ) + { + return SourceItem { QUrl(), configurationMap.value( "groups" ).toList() }; + } + else + { + return SourceItem { QUrl { groupsUrl }, QVariantList() }; + } +} + +LoaderQueue::LoaderQueue( Config* parent ) + : QObject( parent ) + , m_config( parent ) +{ +} + +void +LoaderQueue::append( SourceItem&& i ) +{ + m_queue.append( std::move( i ) ); +} + +void +LoaderQueue::load() +{ + QMetaObject::invokeMethod( this, "fetchNext", Qt::QueuedConnection ); +} + +void +LoaderQueue::fetchNext() +{ + if ( m_queue.isEmpty() ) + { + emit done(); + return; + } + + auto source = m_queue.takeFirst(); + if ( source.isLocal() ) + { + m_config->loadGroupList( source.data ); + emit done(); + } + else + { + fetch( source.url ); + } +} + +void +LoaderQueue::fetch( const QUrl& url ) +{ + FetchNextUnless next( this ); + + if ( !url.isValid() ) + { + m_config->setStatus( Config::Status::FailedBadConfiguration ); + cDebug() << "Invalid URL" << url; + return; + } + + using namespace Calamares::Network; + + cDebug() << "NetInstall loading groups from" << url; + QNetworkReply* reply = Manager().asynchronousGet( + url, + RequestOptions( RequestOptions::FakeUserAgent | RequestOptions::FollowRedirect, std::chrono::seconds( 30 ) ) ); + + if ( !reply ) + { + cDebug() << Logger::SubEntry << "Request failed immediately."; + // If nobody sets a different status, this will remain + m_config->setStatus( Config::Status::FailedBadConfiguration ); + } + else + { + // When the network request is done, **then** we might + // do the next item from the queue, so don't call fetchNext() now. + next.release(); + m_reply = reply; + connect( reply, &QNetworkReply::finished, this, &LoaderQueue::dataArrived ); + } +} + +void +LoaderQueue::dataArrived() +{ + FetchNextUnless next( this ); + + if ( !m_reply || !m_reply->isFinished() ) + { + cWarning() << "NetInstall data called too early."; + m_config->setStatus( Config::Status::FailedInternalError ); + return; + } + + cDebug() << "NetInstall group data received" << m_reply->size() << "bytes from" << m_reply->url(); + + cqDeleter< QNetworkReply > d { m_reply }; + + // If m_required is *false* then we still say we're ready + // even if the reply is corrupt or missing. + if ( m_reply->error() != QNetworkReply::NoError ) + { + cWarning() << "unable to fetch netinstall package lists."; + cDebug() << Logger::SubEntry << "Netinstall reply error: " << m_reply->error(); + cDebug() << Logger::SubEntry << "Request for url: " << m_reply->url().toString() + << " failed with: " << m_reply->errorString(); + m_config->setStatus( Config::Status::FailedNetworkError ); + return; + } + + QByteArray yamlData = m_reply->readAll(); + try + { + auto groups = ::YAML::Load( yamlData.constData() ); + + if ( groups.IsSequence() ) + { + m_config->loadGroupList( Calamares::YAML::sequenceToVariant( groups ) ); + next.done( m_config->statusCode() == Config::Status::Ok ); + } + else if ( groups.IsMap() ) + { + auto map = Calamares::YAML::mapToVariant( groups ); + m_config->loadGroupList( map.value( "groups" ).toList() ); + next.done( m_config->statusCode() == Config::Status::Ok ); + } + else + { + cWarning() << "NetInstall groups data does not form a sequence."; + } + } + catch ( ::YAML::Exception& e ) + { + Calamares::YAML::explainException( e, yamlData, "netinstall groups data" ); + m_config->setStatus( Config::Status::FailedBadData ); + } +} diff --git a/calamares/src/modules/netinstall/LoaderQueue.h b/calamares/src/modules/netinstall/LoaderQueue.h new file mode 100644 index 0000000..d7baf58 --- /dev/null +++ b/calamares/src/modules/netinstall/LoaderQueue.h @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef NETINSTALL_LOADERQUEUE_H +#define NETINSTALL_LOADERQUEUE_H + +#include +#include +#include + +class Config; +class QNetworkReply; + +/** @brief Data about an entry in *groupsUrl* + * + * This can be a specific URL, or "local" which uses data stored + * in the configuration file itself. + */ +struct SourceItem +{ + QUrl url; + QVariantList data; + + bool isUrl() const { return url.isValid(); } + bool isLocal() const { return !data.isEmpty(); } + bool isValid() const { return isUrl() || isLocal(); } + /** @brief Create a SourceItem + * + * If the @p groupsUrl is @c "local" then the *groups* key in + * the @p configurationMap is used as the source; otherwise the + * string is used as an actual URL. + */ + static SourceItem makeSourceItem( const QString& groupsUrl, const QVariantMap& configurationMap ); +}; + +/** @brief Queue of source items to load + * + * Queue things up by calling append() and then kick things off + * by calling load(). This will try to load the items, in order; + * the first one that succeeds will end the loading process. + * + * Signal done() is emitted when done (also when all of the items fail). + */ +class LoaderQueue : public QObject +{ + Q_OBJECT +public: + LoaderQueue( Config* parent ); + + void append( SourceItem&& i ); + int count() const { return m_queue.count(); } + +public Q_SLOTS: + void load(); + + void fetchNext(); + void fetch( const QUrl& url ); + void dataArrived(); + +Q_SIGNALS: + void done(); + +private: + QQueue< SourceItem > m_queue; + Config* m_config = nullptr; + QNetworkReply* m_reply = nullptr; +}; + +#endif diff --git a/calamares/src/modules/netinstall/NetInstallPage.cpp b/calamares/src/modules/netinstall/NetInstallPage.cpp new file mode 100644 index 0000000..9c70e97 --- /dev/null +++ b/calamares/src/modules/netinstall/NetInstallPage.cpp @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-FileCopyrightText: 2017 Gabriel Craciunescu + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "NetInstallPage.h" + +#include "PackageModel.h" +#include "ui_page_netinst.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "network/Manager.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/Yaml.h" + +#include +#include + +NetInstallPage::NetInstallPage( Config* c, QWidget* parent ) + : QWidget( parent ) + , m_config( c ) + , ui( new Ui::Page_NetInst ) +{ + ui->setupUi( this ); + ui->groupswidget->header()->setSectionResizeMode( QHeaderView::ResizeToContents ); + ui->groupswidget->setModel( c->model() ); + connect( c, &Config::statusChanged, ui->netinst_status, &QLabel::setText ); + connect( c, + &Config::titleLabelChanged, + [ ui = this->ui ]( const QString title ) + { + ui->label->setVisible( !title.isEmpty() ); + ui->label->setText( title ); + } ); + connect( c, &Config::statusReady, this, &NetInstallPage::expandGroups ); +} + +NetInstallPage::~NetInstallPage() {} + +void +NetInstallPage::expandGroups() +{ + auto* model = m_config->model(); + // Go backwards because expanding a group may cause rows to appear below it + for ( int i = model->rowCount() - 1; i >= 0; --i ) + { + auto index = model->index( i, 0 ); + if ( model->data( index, PackageModel::MetaExpandRole ).toBool() ) + { + ui->groupswidget->setExpanded( index, true ); + } + } +} + +void +NetInstallPage::onActivate() +{ + ui->groupswidget->setFocus(); + + // The netinstallSelect global storage value can be used to make additional items selected by default + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + const QStringList selectNames = gs->value( "netinstallSelect" ).toStringList(); + if ( !selectNames.isEmpty() ) + { + m_config->model()->setSelections( selectNames ); + } + + // If NetInstallAdd is found in global storage, add those items to the tree + const QVariantList groups = gs->value( "netinstallAdd" ).toList(); + if ( !groups.isEmpty() ) + { + m_config->model()->appendModelData( groups ); + } +} diff --git a/calamares/src/modules/netinstall/NetInstallPage.h b/calamares/src/modules/netinstall/NetInstallPage.h new file mode 100644 index 0000000..5b10b5c --- /dev/null +++ b/calamares/src/modules/netinstall/NetInstallPage.h @@ -0,0 +1,55 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef NETINSTALLPAGE_H +#define NETINSTALLPAGE_H + +#include "Config.h" +#include "PackageModel.h" +#include "PackageTreeItem.h" + +#include "locale/TranslatableConfiguration.h" + +#include +#include + +#include + +class QNetworkReply; + +namespace Ui +{ +class Page_NetInst; +} // namespace Ui + +class NetInstallPage : public QWidget +{ + Q_OBJECT +public: + NetInstallPage( Config* config, QWidget* parent = nullptr ); + ~NetInstallPage() override; + + void onActivate(); + + /** @brief Expand entries that should be pre-expanded. + * + * Follows the *expanded* key / the startExpanded field in the + * group entries of the model. Call this after filling up the model. + */ + void expandGroups(); + +private: + Config* m_config; + Ui::Page_NetInst* ui; +}; + +#endif // NETINSTALLPAGE_H diff --git a/calamares/src/modules/netinstall/NetInstallViewStep.cpp b/calamares/src/modules/netinstall/NetInstallViewStep.cpp new file mode 100644 index 0000000..194b1fd --- /dev/null +++ b/calamares/src/modules/netinstall/NetInstallViewStep.cpp @@ -0,0 +1,138 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "NetInstallViewStep.h" + +#include "NetInstallPage.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( NetInstallViewStepFactory, registerPlugin< NetInstallViewStep >(); ) + +NetInstallViewStep::NetInstallViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_widget( new NetInstallPage( &m_config ) ) + , m_nextEnabled( false ) +{ + connect( &m_config, &Config::statusReady, this, &NetInstallViewStep::nextIsReady ); +} + + +NetInstallViewStep::~NetInstallViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +NetInstallViewStep::prettyName() const +{ + return m_config.sidebarLabel(); + +#if defined( TABLE_OF_TRANSLATIONS ) + __builtin_unreachable(); + // This is a table of "standard" labels for this module. If you use them + // in the label: sidebar: section of the config file, the existing + // translations can be used. + // + // These translations still live here, even though the lookup + // code is in the Config class. + tr( "Package selection" ); + tr( "Office software" ); + tr( "Office package" ); + tr( "Browser software" ); + tr( "Browser package" ); + tr( "Web browser" ); + tr( "Kernel", "label for netinstall module, Linux kernel" ); + tr( "Services", "label for netinstall module, system services" ); + tr( "Login", "label for netinstall module, choose login manager" ); + tr( "Desktop", "label for netinstall module, choose desktop environment" ); + tr( "Applications" ); + tr( "Communication", "label for netinstall module" ); + tr( "Development", "label for netinstall module" ); + tr( "Office", "label for netinstall module" ); + tr( "Multimedia", "label for netinstall module" ); + tr( "Internet", "label for netinstall module" ); + tr( "Theming", "label for netinstall module" ); + tr( "Gaming", "label for netinstall module" ); + tr( "Utilities", "label for netinstall module" ); +#endif +} + + +QWidget* +NetInstallViewStep::widget() +{ + return m_widget; +} + + +bool +NetInstallViewStep::isNextEnabled() const +{ + return !m_config.required() || m_nextEnabled; +} + + +bool +NetInstallViewStep::isBackEnabled() const +{ + return true; +} + + +bool +NetInstallViewStep::isAtBeginning() const +{ + return true; +} + + +bool +NetInstallViewStep::isAtEnd() const +{ + return true; +} + + +Calamares::JobList +NetInstallViewStep::jobs() const +{ + return Calamares::JobList(); +} + + +void +NetInstallViewStep::onActivate() +{ + m_widget->onActivate(); +} + +void +NetInstallViewStep::onLeave() +{ + m_config.finalizeGlobalStorage( moduleInstanceKey() ); +} + +void +NetInstallViewStep::nextIsReady() +{ + m_nextEnabled = true; + emit nextStatusChanged( true ); +} + +void +NetInstallViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config.setConfigurationMap( configurationMap ); +} diff --git a/calamares/src/modules/netinstall/NetInstallViewStep.h b/calamares/src/modules/netinstall/NetInstallViewStep.h new file mode 100644 index 0000000..8949632 --- /dev/null +++ b/calamares/src/modules/netinstall/NetInstallViewStep.h @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2016 Luca Giambonini + * SPDX-FileCopyrightText: 2016 Lisa Vitolo + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef NETINSTALLVIEWSTEP_H +#define NETINSTALLVIEWSTEP_H + +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include + +class NetInstallPage; + +class PLUGINDLLEXPORT NetInstallViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit NetInstallViewStep( QObject* parent = nullptr ); + ~NetInstallViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onActivate() override; + + // Leaving the page; store all selected packages for later installation. + void onLeave() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +public slots: + void nextIsReady(); + +private: + Config m_config; + + NetInstallPage* m_widget; + bool m_nextEnabled = false; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( NetInstallViewStepFactory ) + +#endif // NETINSTALLVIEWSTEP_H diff --git a/calamares/src/modules/netinstall/PackageModel.cpp b/calamares/src/modules/netinstall/PackageModel.cpp new file mode 100644 index 0000000..98eb446 --- /dev/null +++ b/calamares/src/modules/netinstall/PackageModel.cpp @@ -0,0 +1,392 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PackageModel.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" +#include "utils/Variant.h" +#include "utils/Yaml.h" + +/// Recursive helper for setSelections() +static void +setSelections( const QStringList& selectNames, PackageTreeItem* item ) +{ + for ( int i = 0; i < item->childCount(); i++ ) + { + auto* child = item->child( i ); + setSelections( selectNames, child ); + } + if ( item->isGroup() && selectNames.contains( item->name() ) ) + { + item->setSelected( Qt::CheckState::Checked ); + } +} + +/** @brief Collects all the "source" values from @p groupList + * + * Iterates over @p groupList and returns all nonempty "source" + * values from the maps. + * + */ +static QStringList +collectSources( const QVariantList& groupList ) +{ + QStringList sources; + for ( const QVariant& group : groupList ) + { + QVariantMap groupMap = group.toMap(); + if ( !groupMap[ "source" ].toString().isEmpty() ) + { + sources.append( groupMap[ "source" ].toString() ); + } + } + + return sources; +} + +PackageModel::PackageModel( QObject* parent ) + : QAbstractItemModel( parent ) +{ +} + +PackageModel::~PackageModel() +{ + delete m_rootItem; +} + +QModelIndex +PackageModel::index( int row, int column, const QModelIndex& parent ) const +{ + if ( !m_rootItem || !hasIndex( row, column, parent ) ) + { + return QModelIndex(); + } + + PackageTreeItem* parentItem; + + if ( !parent.isValid() ) + { + parentItem = m_rootItem; + } + else + { + parentItem = static_cast< PackageTreeItem* >( parent.internalPointer() ); + } + + PackageTreeItem* childItem = parentItem->child( row ); + if ( childItem ) + { + return createIndex( row, column, childItem ); + } + else + { + return QModelIndex(); + } +} + +QModelIndex +PackageModel::parent( const QModelIndex& index ) const +{ + if ( !m_rootItem || !index.isValid() ) + { + return QModelIndex(); + } + + PackageTreeItem* child = static_cast< PackageTreeItem* >( index.internalPointer() ); + PackageTreeItem* parent = child->parentItem(); + + if ( parent == m_rootItem ) + { + return QModelIndex(); + } + return createIndex( parent->row(), 0, parent ); +} + +int +PackageModel::rowCount( const QModelIndex& parent ) const +{ + if ( !m_rootItem || ( parent.column() > 0 ) ) + { + return 0; + } + + PackageTreeItem* parentItem; + if ( !parent.isValid() ) + { + parentItem = m_rootItem; + } + else + { + parentItem = static_cast< PackageTreeItem* >( parent.internalPointer() ); + } + + return parentItem->childCount(); +} + +int +PackageModel::columnCount( const QModelIndex& ) const +{ + return 2; +} + +QVariant +PackageModel::data( const QModelIndex& index, int role ) const +{ + if ( !m_rootItem || !index.isValid() ) + { + return QVariant(); + } + + PackageTreeItem* item = static_cast< PackageTreeItem* >( index.internalPointer() ); + switch ( role ) + { + case Qt::CheckStateRole: + return index.column() == NameColumn ? ( item->isImmutable() ? QVariant() : item->isSelected() ) : QVariant(); + case Qt::DisplayRole: + return item->isHidden() ? QVariant() : item->data( index.column() ); + case MetaExpandRole: + return item->isHidden() ? false : item->expandOnStart(); + default: + return QVariant(); + } +} + +bool +PackageModel::setData( const QModelIndex& index, const QVariant& value, int role ) +{ + if ( !m_rootItem ) + { + return false; + } + + if ( role == Qt::CheckStateRole && index.isValid() ) + { + PackageTreeItem* item = static_cast< PackageTreeItem* >( index.internalPointer() ); + item->setSelected( static_cast< Qt::CheckState >( value.toInt() ) ); + + emit dataChanged( this->index( 0, 0 ), + index.sibling( index.column(), index.row() + 1 ), + QVector< int >( Qt::CheckStateRole ) ); + } + return true; +} + +Qt::ItemFlags +PackageModel::flags( const QModelIndex& index ) const +{ + if ( !m_rootItem || !index.isValid() ) + { + return Qt::ItemFlags(); + } + if ( index.column() == NameColumn ) + { + PackageTreeItem* item = static_cast< PackageTreeItem* >( index.internalPointer() ); + if ( item->isImmutable() || item->isNoncheckable() ) + { + return QAbstractItemModel::flags( index ); //Qt::NoItemFlags; + } + return Qt::ItemIsUserCheckable | QAbstractItemModel::flags( index ); + } + return QAbstractItemModel::flags( index ); +} + +QVariant +PackageModel::headerData( int section, Qt::Orientation orientation, int role ) const +{ + if ( orientation == Qt::Horizontal && role == Qt::DisplayRole ) + { + return ( section == NameColumn ) ? tr( "Name" ) : tr( "Description" ); + } + return QVariant(); +} + +void +PackageModel::setSelections( const QStringList& selectNames ) +{ + if ( m_rootItem ) + { + ::setSelections( selectNames, m_rootItem ); + } +} + +PackageTreeItem::List +PackageModel::getPackages() const +{ + if ( !m_rootItem ) + { + return PackageTreeItem::List(); + } + + auto items = getItemPackages( m_rootItem ); + for ( auto package : m_hiddenItems ) + { + if ( package->hiddenSelected() ) + { + items.append( getItemPackages( package ) ); + } + } + return items; +} + +PackageTreeItem::List +PackageModel::getItemPackages( PackageTreeItem* item ) const +{ + PackageTreeItem::List selectedPackages; + for ( int i = 0; i < item->childCount(); i++ ) + { + auto* child = item->child( i ); + if ( child->isSelected() == Qt::Unchecked ) + { + continue; + } + + if ( child->isPackage() ) // package + { + selectedPackages.append( child ); + } + else + { + selectedPackages.append( getItemPackages( child ) ); + } + } + return selectedPackages; +} + +void +PackageModel::setupModelData( const QVariantList& groupList, PackageTreeItem* parent ) +{ + for ( const auto& group : groupList ) + { + QVariantMap groupMap = group.toMap(); + if ( groupMap.isEmpty() ) + { + continue; + } + + PackageTreeItem* item = new PackageTreeItem( groupMap, PackageTreeItem::GroupTag { parent } ); + if ( groupMap.contains( "selected" ) ) + { + item->setSelected( Calamares::getBool( groupMap, "selected", false ) ? Qt::Checked : Qt::Unchecked ); + } + if ( groupMap.contains( "packages" ) ) + { + for ( const auto& packageName : groupMap.value( "packages" ).toList() ) + { + if ( Calamares::typeOf( packageName ) == Calamares::StringVariantType ) + { + item->appendChild( new PackageTreeItem( packageName.toString(), item ) ); + } + else + { + QVariantMap m = packageName.toMap(); + if ( !m.isEmpty() ) + { + item->appendChild( new PackageTreeItem( m, PackageTreeItem::PackageTag { item } ) ); + } + } + } + if ( !item->childCount() ) + { + cWarning() << "*packages* under" << item->name() << "is empty."; + } + } + if ( groupMap.contains( "subgroups" ) ) + { + bool haveWarned = false; + const auto& subgroupValue = groupMap.value( "subgroups" ); + if ( !subgroupValue.canConvert< QVariantList >() ) + { + cWarning() << "*subgroups* under" << item->name() << "is not a list."; + haveWarned = true; + } + + QVariantList subgroups = groupMap.value( "subgroups" ).toList(); + if ( !subgroups.isEmpty() ) + { + setupModelData( subgroups, item ); + // The children might be checked while the parent isn't (yet). + // Children are added to their parent (below) without affecting + // the checked-state -- do it manually. Items with subgroups + // but no children have only hidden children -- those get + // handled specially. + if ( item->childCount() > 0 ) + { + item->updateSelected(); + } + } + else + { + if ( !haveWarned ) + { + cWarning() << "*subgroups* list under" << item->name() << "is empty."; + } + } + } + if ( item->isHidden() ) + { + m_hiddenItems.append( item ); + if ( !item->isSelected() ) + { + cWarning() << "Item" << ( item->parentItem() ? item->parentItem()->name() : QString() ) << '.' + << item->name() << "is hidden, but not selected."; + } + } + else + { + item->setCheckable( true ); + parent->appendChild( item ); + } + } +} + +void +PackageModel::setupModelData( const QVariantList& l ) +{ + beginResetModel(); + delete m_rootItem; + m_rootItem = new PackageTreeItem(); + setupModelData( l, m_rootItem ); + endResetModel(); +} + +void +PackageModel::appendModelData( const QVariantList& groupList ) +{ + if ( m_rootItem ) + { + beginResetModel(); + + const QStringList sources = collectSources( groupList ); + + if ( !sources.isEmpty() ) + { + // Prune any existing data from the same source + QList< int > removeList; + for ( int i = 0; i < m_rootItem->childCount(); i++ ) + { + PackageTreeItem* child = m_rootItem->child( i ); + if ( sources.contains( child->source() ) ) + { + removeList.insert( 0, i ); + } + } + for ( const int& item : std::as_const( removeList ) ) + { + m_rootItem->removeChild( item ); + } + } + + // Add the new data to the model + setupModelData( groupList, m_rootItem ); + + endResetModel(); + } +} diff --git a/calamares/src/modules/netinstall/PackageModel.h b/calamares/src/modules/netinstall/PackageModel.h new file mode 100644 index 0000000..01334fe --- /dev/null +++ b/calamares/src/modules/netinstall/PackageModel.h @@ -0,0 +1,92 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGEMODEL_H +#define PACKAGEMODEL_H + +#include "PackageTreeItem.h" + +#include +#include +#include + +namespace YAML +{ +class Node; +} // namespace YAML + +class PackageModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + // Names for columns (unused in the code) + static constexpr const int NameColumn = 0; + static constexpr const int DescriptionColumn = 1; + + /* The only interesting roles are DisplayRole (with text depending + * on the column, and MetaExpandRole which tells if an index + * should be initially expanded. + */ + static constexpr const int MetaExpandRole = Qt::UserRole + 1; + + explicit PackageModel( QObject* parent = nullptr ); + ~PackageModel() override; + + void setupModelData( const QVariantList& l ); + + QVariant data( const QModelIndex& index, int role ) const override; + bool setData( const QModelIndex& index, const QVariant& value, int role = Qt::EditRole ) override; + Qt::ItemFlags flags( const QModelIndex& index ) const override; + + QModelIndex index( int row, int column, const QModelIndex& parent = QModelIndex() ) const override; + QModelIndex parent( const QModelIndex& index ) const override; + + QVariant headerData( int section, Qt::Orientation orientation, int role = Qt::DisplayRole ) const override; + int rowCount( const QModelIndex& parent = QModelIndex() ) const override; + int columnCount( const QModelIndex& parent = QModelIndex() ) const override; + + /** @brief Sets the checked flag on matching groups in the tree + * + * Recursively traverses the tree pointed to by m_rootItem and + * checks if a group name matches any of the items in @p selectNames. + * If a match is found, set check the box for that group and it's children. + * + * Individual packages will not be matched. + * + */ + void setSelections( const QStringList& selectNames ); + + PackageTreeItem::List getPackages() const; + PackageTreeItem::List getItemPackages( PackageTreeItem* item ) const; + + /** @brief Appends groups to the tree + * + * Uses the data from @p groupList to add elements to the + * existing tree that m_rootItem points to. If m_rootItem + * is not valid, it does nothing + * + * Before adding anything to the model, it ensures that there + * is no existing data from the same source. If there is, that + * data is pruned first + * + */ + void appendModelData( const QVariantList& groupList ); + +private: + friend class ItemTests; + + void setupModelData( const QVariantList& l, PackageTreeItem* parent ); + + PackageTreeItem* m_rootItem = nullptr; + PackageTreeItem::List m_hiddenItems; +}; + +#endif // PACKAGEMODEL_H diff --git a/calamares/src/modules/netinstall/PackageTreeItem.cpp b/calamares/src/modules/netinstall/PackageTreeItem.cpp new file mode 100644 index 0000000..76f97fa --- /dev/null +++ b/calamares/src/modules/netinstall/PackageTreeItem.cpp @@ -0,0 +1,311 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PackageTreeItem.h" + +#include "utils/Logger.h" +#include "utils/Variant.h" + +/** @brief Should a package be selected, given its parent's state? */ +static Qt::CheckState +parentCheckState( PackageTreeItem* parent ) +{ + if ( parent ) + { + // Avoid partially-checked .. a package can't be partial + return parent->isSelected() == Qt::Unchecked ? Qt::Unchecked : Qt::Checked; + } + else + { + return Qt::Unchecked; + } +} + +/** @brief Should a subgroup be marked critical? + * + * If set explicitly, then use that, otherwise use the parent's critical-ness. + */ +static bool +parentCriticality( const QVariantMap& groupData, PackageTreeItem* parent ) +{ + if ( groupData.contains( "critical" ) ) + { + return Calamares::getBool( groupData, "critical", false ); + } + return parent ? parent->isCritical() : false; +} + +PackageTreeItem::PackageTreeItem( const QString& packageName, PackageTreeItem* parent ) + : m_parentItem( parent ) + , m_packageName( packageName ) + , m_selected( parentCheckState( parent ) ) + , m_isGroup( false ) + , m_isCritical( parent ? parent->isCritical() : false ) + , m_showReadOnly( parent ? parent->isImmutable() : false ) + , m_showNoncheckable( false ) +{ +} + +PackageTreeItem::PackageTreeItem( const QVariantMap& groupData, PackageTag&& parent ) + : m_parentItem( parent.parent ) + , m_packageName( Calamares::getString( groupData, "name" ) ) + , m_selected( parentCheckState( parent.parent ) ) + , m_description( Calamares::getString( groupData, "description" ) ) + , m_isGroup( false ) + , m_isCritical( parent.parent ? parent.parent->isCritical() : false ) + , m_showReadOnly( parent.parent ? parent.parent->isImmutable() : false ) + , m_showNoncheckable( false ) +{ +} + +PackageTreeItem::PackageTreeItem( const QVariantMap& groupData, GroupTag&& parent ) + : m_parentItem( parent.parent ) + , m_name( Calamares::getString( groupData, "name" ) ) + , m_selected( parentCheckState( parent.parent ) ) + , m_description( Calamares::getString( groupData, "description" ) ) + , m_preScript( Calamares::getString( groupData, "pre-install" ) ) + , m_postScript( Calamares::getString( groupData, "post-install" ) ) + , m_source( Calamares::getString( groupData, "source" ) ) + , m_isGroup( true ) + , m_isCritical( parentCriticality( groupData, parent.parent ) ) + , m_isHidden( Calamares::getBool( groupData, "hidden", false ) ) + , m_showReadOnly( Calamares::getBool( groupData, "immutable", false ) ) + , m_showNoncheckable( Calamares::getBool( groupData, "noncheckable", false ) ) + , m_startExpanded( Calamares::getBool( groupData, "expanded", false ) ) +{ +} + +PackageTreeItem::PackageTreeItem::PackageTreeItem() + : m_parentItem( nullptr ) + , m_name( QStringLiteral( "" ) ) + , m_selected( Qt::Checked ) + , m_isGroup( true ) +{ +} + +PackageTreeItem::~PackageTreeItem() +{ + qDeleteAll( m_childItems ); +} + +void +PackageTreeItem::appendChild( PackageTreeItem* child ) +{ + m_childItems.append( child ); +} + +PackageTreeItem* +PackageTreeItem::child( int row ) +{ + return m_childItems.value( row ); +} + +int +PackageTreeItem::childCount() const +{ + return m_childItems.count(); +} + +int +PackageTreeItem::row() const +{ + if ( m_parentItem ) + { + return m_parentItem->m_childItems.indexOf( const_cast< PackageTreeItem* >( this ) ); + } + return 0; +} + +QVariant +PackageTreeItem::data( int column ) const +{ + switch ( column ) + { + case 0: + // packages have a packagename, groups don't + return QVariant( isPackage() ? packageName() : name() ); + case 1: + // packages often have a blank description + return QVariant( description() ); + default: + return QVariant(); + } +} + +PackageTreeItem* +PackageTreeItem::parentItem() +{ + return m_parentItem; +} + +const PackageTreeItem* +PackageTreeItem::parentItem() const +{ + return m_parentItem; +} + +bool +PackageTreeItem::hiddenSelected() const +{ + if ( !m_isHidden ) + { + return m_selected != Qt::Unchecked; + } + + if ( m_selected == Qt::Unchecked ) + { + return false; + } + + const PackageTreeItem* currentItem = parentItem(); + while ( currentItem != nullptr ) + { + if ( !currentItem->isHidden() ) + { + return currentItem->isSelected() != Qt::Unchecked; + } + currentItem = currentItem->parentItem(); + } + + /* Has no non-hidden parents */ + return m_selected != Qt::Unchecked; +} + +void +PackageTreeItem::setSelected( Qt::CheckState isSelected ) +{ + if ( parentItem() == nullptr ) + { + // This is the root, it is always checked so don't change state + return; + } + + m_selected = isSelected; + setChildrenSelected( isSelected ); + + // Look for suitable parent item which may change checked-state + // when one of its children changes. + PackageTreeItem* currentItem = parentItem(); + while ( ( currentItem != nullptr ) && ( currentItem->childCount() == 0 ) ) + { + currentItem = currentItem->parentItem(); + } + if ( currentItem == nullptr ) + { + // Reached the root .. don't bother + return; + } + + currentItem->updateSelected(); +} + +void +PackageTreeItem::updateSelected() +{ + // Figure out checked-state based on the children + int childrenSelected = 0; + int childrenPartiallySelected = 0; + for ( int i = 0; i < childCount(); i++ ) + { + if ( child( i )->isSelected() == Qt::Checked ) + { + childrenSelected++; + } + if ( child( i )->isSelected() == Qt::PartiallyChecked ) + { + childrenPartiallySelected++; + } + } + if ( !childrenSelected && !childrenPartiallySelected ) + { + setSelected( Qt::Unchecked ); + } + else if ( childrenSelected == childCount() ) + { + setSelected( Qt::Checked ); + } + else + { + setSelected( Qt::PartiallyChecked ); + } +} + +void +PackageTreeItem::setChildrenSelected( Qt::CheckState isSelected ) +{ + if ( isSelected != Qt::PartiallyChecked ) + { + // Children are never root; don't need to use setSelected on them. + for ( auto child : m_childItems ) + { + child->m_selected = isSelected; + child->setChildrenSelected( isSelected ); + } + } +} + +void +PackageTreeItem::removeChild( int row ) +{ + if ( 0 <= row && row < m_childItems.count() ) + { + m_childItems.removeAt( row ); + } + else + { + cWarning() << "Attempt to remove invalid child in removeChild() at row " << row; + } +} + +int +PackageTreeItem::type() const +{ + return QStandardItem::UserType; +} + +QVariant +PackageTreeItem::toOperation() const +{ + // If it's a package with a pre- or post-script, replace + // with the more complicated datastructure. + if ( !m_preScript.isEmpty() || !m_postScript.isEmpty() ) + { + QMap< QString, QVariant > sdetails; + sdetails.insert( "pre-script", m_preScript ); + sdetails.insert( "package", m_packageName ); + sdetails.insert( "post-script", m_postScript ); + return sdetails; + } + else + { + return m_packageName; + } +} + +bool +PackageTreeItem::operator==( const PackageTreeItem& rhs ) const +{ + if ( isGroup() != rhs.isGroup() ) + { + // Different kinds + return false; + } + + if ( isGroup() ) + { + return name() == rhs.name() && description() == rhs.description() && preScript() == rhs.preScript() + && postScript() == rhs.postScript() && isCritical() == rhs.isCritical() && isHidden() == rhs.isHidden() + && m_showReadOnly == rhs.m_showReadOnly && expandOnStart() == rhs.expandOnStart(); + } + else + { + return packageName() == rhs.packageName(); + } +} diff --git a/calamares/src/modules/netinstall/PackageTreeItem.h b/calamares/src/modules/netinstall/PackageTreeItem.h new file mode 100644 index 0000000..074bc3d --- /dev/null +++ b/calamares/src/modules/netinstall/PackageTreeItem.h @@ -0,0 +1,179 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Kyle Robbertze + * SPDX-FileCopyrightText: 2017 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGETREEITEM_H +#define PACKAGETREEITEM_H + +#include +#include +#include + +class PackageTreeItem : public QStandardItem +{ +public: + using List = QList< PackageTreeItem* >; + + ///@brief A tag class to distinguish package-from-map from group-from-map + struct PackageTag + { + PackageTreeItem* parent; + }; + ///@brief A tag class to distinguish group-from-map from package-from-map + struct GroupTag + { + PackageTreeItem* parent; + }; + + ///@brief A package (individual package) + explicit PackageTreeItem( const QString& packageName, PackageTreeItem* parent = nullptr ); + ///@brief A package (individual package with description) + explicit PackageTreeItem( const QVariantMap& packageData, PackageTag&& parent ); + ///@brief A group (sub-items and sub-groups are ignored) + explicit PackageTreeItem( const QVariantMap& groupData, GroupTag&& parent ); + ///@brief A root item, always selected, named "" + explicit PackageTreeItem(); + ~PackageTreeItem() override; + + void appendChild( PackageTreeItem* child ); + PackageTreeItem* child( int row ); + int childCount() const; + QVariant data( int column ) const override; + int row() const; + + PackageTreeItem* parentItem(); + const PackageTreeItem* parentItem() const; + + QString name() const { return m_name; } + QString packageName() const { return m_packageName; } + + QString description() const { return m_description; } + QString preScript() const { return m_preScript; } + QString postScript() const { return m_postScript; } + QString source() const { return m_source; } + + /** @brief Is this item a group-item? + * + * Groups have a (possibly empty) list of packages, and a + * (possibly empty) list of sub-groups, and can be marked + * critical, hidden, etc. Packages, on the other hand, only + * have a meaningful packageName() and selection status. + * + * Root is a group. + */ + bool isGroup() const { return m_isGroup; } + + /// @brief Is this item a single package? + bool isPackage() const { return !isGroup(); } + + /** @brief Is this item hidden? + * + * Hidden items (generally only groups) are maintained separately, + * not shown to the user, but do enter into the package-installation process. + */ + bool isHidden() const { return m_isHidden; } + + /** @brief Is this hidden item, considered "selected"? + * + * This asserts when called on a non-hidden item. + * A hidden item has its own selected state, but really + * falls under the selectedness of the parent item. + */ + bool hiddenSelected() const; + + /** @brief Is this group critical? + * + * A critical group must be successfully installed, for the Calamares + * installation to continue. + */ + bool isCritical() const { return m_isCritical; } + + /** @brief Is this group expanded on start? + * + * This does not affect installation, only the UI. A group + * that expands on start is shown expanded (not collapsed) + * in the treeview when the page is loaded. + */ + bool expandOnStart() const { return m_startExpanded; } + + /** @brief Is this an immutable item? + * + * Groups can be immutable: then you can't toggle the selected + * state of any of its items. + */ + bool isImmutable() const { return m_showReadOnly; } + + /** @brief Is this a non-checkable item? + * + * Groups can be non-checkable: then you can't toggle the selected + * state of the group. This does not affect subgroups or packages. + */ + bool isNoncheckable() const { return m_showNoncheckable; } + + /** @brief is this item selected? + * + * Groups may be partially selected; packages are only on or off. + */ + Qt::CheckState isSelected() const { return m_selected; } + + /** @brief Turns this item into a variant for PackageOperations use + * + * For "plain" items, this is just the package name; items with + * scripts return a map. See the package module for how it's interpreted. + */ + QVariant toOperation() const; + + void setSelected( Qt::CheckState isSelected ); + void setChildrenSelected( Qt::CheckState isSelected ); + + void removeChild( int row ); + + /** @brief Update selectedness based on the children's states + * + * This only makes sense for groups, which might have packages + * or subgroups; it checks only direct children. + */ + void updateSelected(); + + // QStandardItem methods + int type() const override; + + /** @brief Are two items equal + * + * This **disregards** parent-item and the child-items, and compares + * only the fields for the items-proper (name, .. expanded). Note + * also that *isSelected()* is a run-time state, and is **not** + * compared either. + */ + bool operator==( const PackageTreeItem& rhs ) const; + bool operator!=( const PackageTreeItem& rhs ) const { return !( *this == rhs ); } + +private: + PackageTreeItem* m_parentItem; + List m_childItems; + + // An entry can be a package, or a group. + QString m_name; + QString m_packageName; + Qt::CheckState m_selected = Qt::Unchecked; + + // These are only useful for groups + QString m_description; + QString m_preScript; + QString m_postScript; + QString m_source; + bool m_isGroup = false; + bool m_isCritical = false; + bool m_isHidden = false; + bool m_showReadOnly = false; + bool m_showNoncheckable = false; + bool m_startExpanded = false; +}; + +#endif // PACKAGETREEITEM_H diff --git a/calamares/src/modules/netinstall/Tests.cpp b/calamares/src/modules/netinstall/Tests.cpp new file mode 100644 index 0000000..8e93322 --- /dev/null +++ b/calamares/src/modules/netinstall/Tests.cpp @@ -0,0 +1,425 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" +#include "PackageModel.h" +#include "PackageTreeItem.h" + +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Variant.h" +#include "utils/Yaml.h" + +#include + +#include + +class ItemTests : public QObject +{ + Q_OBJECT +public: + ItemTests(); + ~ItemTests() override {} + +private: + void checkAllSelected( PackageTreeItem* p ); + void recursiveCompare( PackageTreeItem*, PackageTreeItem* ); + void recursiveCompare( PackageModel&, PackageModel& ); + +private Q_SLOTS: + void initTestCase(); + + void testRoot(); + + void testPackage(); + void testExtendedPackage(); + + void testGroup(); + void testCompare(); + void testModel(); + void testExampleFiles(); + + void testUrlFallback_data(); + void testUrlFallback(); +}; + +ItemTests::ItemTests() {} + +void +ItemTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +ItemTests::testRoot() +{ + PackageTreeItem r; + + QCOMPARE( r.isSelected(), Qt::Checked ); + QCOMPARE( r.name(), QStringLiteral( "" ) ); + QCOMPARE( r.parentItem(), nullptr ); + QVERIFY( r.isGroup() ); + + QVERIFY( r == r ); +} + +void +ItemTests::testPackage() +{ + PackageTreeItem p( "bash", nullptr ); + + QCOMPARE( p.isSelected(), Qt::Unchecked ); + QCOMPARE( p.packageName(), QStringLiteral( "bash" ) ); + QVERIFY( p.name().isEmpty() ); // not a group + QVERIFY( p.description().isEmpty() ); + QCOMPARE( p.parentItem(), nullptr ); + QCOMPARE( p.childCount(), 0 ); + QVERIFY( !p.isHidden() ); + QVERIFY( !p.isCritical() ); + QVERIFY( !p.isGroup() ); + QVERIFY( p.isPackage() ); + QVERIFY( p == p ); + + // This doesn't happen in normal constructions, + // because a package can't have children. + PackageTreeItem c( "zsh", &p ); + QCOMPARE( c.isSelected(), Qt::Unchecked ); + QCOMPARE( c.packageName(), QStringLiteral( "zsh" ) ); + QVERIFY( c.name().isEmpty() ); // not a group + QCOMPARE( c.parentItem(), &p ); + QVERIFY( !c.isGroup() ); + QVERIFY( c.isPackage() ); + QVERIFY( c == c ); + QVERIFY( c != p ); + + QCOMPARE( p.childCount(), 0 ); // not noticed it has a child +} + +// *INDENT-OFF* +// clang-format off +static const char doc[] = +"- name: \"CCR\"\n" +" description: \"Tools for the Chakra Community Repository\"\n" +" packages:\n" +" - ccr\n" +" - base-devel\n" +" - bash\n"; + +static const char doc_no_packages[] = +"- name: \"CCR\"\n" +" description: \"Tools for the Chakra Community Repository\"\n" +" packages: []\n"; + +static const char doc_with_expanded[] = +"- name: \"CCR\"\n" +" description: \"Tools for the Chakra Community Repository\"\n" +" expanded: true\n" +" packages:\n" +" - ccr\n" +" - base-devel\n" +" - bash\n"; +// *INDENT-ON* +// clang-format on + +void +ItemTests::testExtendedPackage() +{ + auto yamldoc = ::YAML::Load( doc ); + QVariantList yamlContents = Calamares::YAML::sequenceToVariant( yamldoc ); + + QCOMPARE( yamlContents.length(), 1 ); + + // Kind of derpy, but we can treat a group as if it is a package + // because the keys name and description are the same + PackageTreeItem p( yamlContents[ 0 ].toMap(), PackageTreeItem::PackageTag { nullptr } ); + + QCOMPARE( p.isSelected(), Qt::Unchecked ); + QCOMPARE( p.packageName(), QStringLiteral( "CCR" ) ); + QVERIFY( p.name().isEmpty() ); // not a group + QVERIFY( !p.description().isEmpty() ); // because it is set + QVERIFY( p.description().startsWith( QStringLiteral( "Tools for the Chakra" ) ) ); + QCOMPARE( p.parentItem(), nullptr ); + QCOMPARE( p.childCount(), 0 ); + QVERIFY( !p.isHidden() ); + QVERIFY( !p.isCritical() ); + QVERIFY( !p.isGroup() ); + QVERIFY( p.isPackage() ); + QVERIFY( p == p ); +} + +void +ItemTests::testGroup() +{ + auto yamldoc = ::YAML::Load( doc ); + QVariantList yamlContents = Calamares::YAML::sequenceToVariant( yamldoc ); + + QCOMPARE( yamlContents.length(), 1 ); + + PackageTreeItem p( yamlContents[ 0 ].toMap(), PackageTreeItem::GroupTag { nullptr } ); + QCOMPARE( p.name(), QStringLiteral( "CCR" ) ); + QVERIFY( p.packageName().isEmpty() ); + QVERIFY( p.description().startsWith( QStringLiteral( "Tools " ) ) ); + QCOMPARE( p.parentItem(), nullptr ); + QVERIFY( !p.isHidden() ); + QVERIFY( !p.isCritical() ); + // The item-constructor doesn't consider the packages: list + QCOMPARE( p.childCount(), 0 ); + QVERIFY( p.isGroup() ); + QVERIFY( !p.isPackage() ); + QVERIFY( p == p ); + + PackageTreeItem c( "zsh", nullptr ); // Single string, package + QVERIFY( p != c ); +} + +void +ItemTests::testCompare() +{ + PackageTreeItem p0( "bash", nullptr ); + PackageTreeItem p1( "bash", &p0 ); + PackageTreeItem p2( "bash", nullptr ); + + QVERIFY( p0 == p1 ); // Parent doesn't matter + QVERIFY( p0 == p2 ); + + p2.setSelected( Qt::Checked ); + p1.setSelected( Qt::Unchecked ); + QVERIFY( p0 == p1 ); // Neither does selected state + QVERIFY( p0 == p2 ); + + PackageTreeItem r0( nullptr ); + QVERIFY( p0 != r0 ); + QVERIFY( p1 != r0 ); + QVERIFY( r0 == r0 ); + PackageTreeItem r1( nullptr ); + QVERIFY( r0 == r1 ); // Different roots are still equal + + PackageTreeItem r2( "", nullptr ); // Fake root + QVERIFY( r0 != r2 ); + QVERIFY( r1 != r2 ); + QVERIFY( p0 != r2 ); + PackageTreeItem r3( "", nullptr ); + QVERIFY( r3 == r2 ); + + auto yamldoc = ::YAML::Load( doc ); // See testGroup() + QVariantList yamlContents = Calamares::YAML::sequenceToVariant( yamldoc ); + QCOMPARE( yamlContents.length(), 1 ); + + PackageTreeItem p3( yamlContents[ 0 ].toMap(), PackageTreeItem::GroupTag { nullptr } ); + QVERIFY( p3 == p3 ); + QVERIFY( p3 != p1 ); + QVERIFY( p1 != p3 ); + QCOMPARE( p3.childCount(), 0 ); // Doesn't load the packages: list + + PackageTreeItem p4( Calamares::YAML::sequenceToVariant( YAML::Load( doc ) )[ 0 ].toMap(), + PackageTreeItem::GroupTag { nullptr } ); + QVERIFY( p3 == p4 ); + PackageTreeItem p5( Calamares::YAML::sequenceToVariant( YAML::Load( doc_no_packages ) )[ 0 ].toMap(), + PackageTreeItem::GroupTag { nullptr } ); + QVERIFY( p3 == p5 ); +} + +void +ItemTests::checkAllSelected( PackageTreeItem* p ) +{ + QVERIFY( p->isSelected() ); + for ( int i = 0; i < p->childCount(); ++i ) + { + checkAllSelected( p->child( i ) ); + } +} + +void +ItemTests::recursiveCompare( PackageTreeItem* l, PackageTreeItem* r ) +{ + QVERIFY( l && r ); + QVERIFY( *l == *r ); + QCOMPARE( l->childCount(), r->childCount() ); + + for ( int i = 0; i < l->childCount(); ++i ) + { + QCOMPARE( l->childCount(), r->childCount() ); + recursiveCompare( l->child( i ), r->child( i ) ); + } +} + +void +ItemTests::recursiveCompare( PackageModel& l, PackageModel& r ) +{ + return recursiveCompare( l.m_rootItem, r.m_rootItem ); +} + +void +ItemTests::testModel() +{ + auto yamldoc = ::YAML::Load( doc ); // See testGroup() + QVariantList yamlContents = Calamares::YAML::sequenceToVariant( yamldoc ); + QCOMPARE( yamlContents.length(), 1 ); + + PackageModel m0( nullptr ); + m0.setupModelData( yamlContents ); + + QCOMPARE( m0.m_hiddenItems.count(), 0 ); // Nothing hidden + QCOMPARE( m0.rowCount(), 1 ); // Group, the packages are invisible + QCOMPARE( m0.rowCount( m0.index( 0, 0 ) ), 3 ); // The packages + + checkAllSelected( m0.m_rootItem ); + + PackageModel m2( nullptr ); + m2.setupModelData( Calamares::YAML::sequenceToVariant( YAML::Load( doc_with_expanded ) ) ); + QCOMPARE( m2.m_hiddenItems.count(), 0 ); + QCOMPARE( m2.rowCount(), 1 ); // Group, now the packages expanded but not counted + QCOMPARE( m2.rowCount( m2.index( 0, 0 ) ), 3 ); // The packages + checkAllSelected( m2.m_rootItem ); + + PackageTreeItem r; + QVERIFY( r == *m0.m_rootItem ); + + QCOMPARE( m0.m_rootItem->childCount(), 1 ); + + PackageTreeItem* group = m0.m_rootItem->child( 0 ); + QVERIFY( group->isGroup() ); + QCOMPARE( group->name(), QStringLiteral( "CCR" ) ); + QCOMPARE( group->childCount(), 3 ); + + PackageTreeItem bash( "bash", nullptr ); + // Check that the sub-packages loaded correctly + bool found_one_bash = false; + for ( int i = 0; i < group->childCount(); ++i ) + { + QVERIFY( group->child( i )->isPackage() ); + if ( bash == *( group->child( i ) ) ) + { + found_one_bash = true; + } + } + QVERIFY( found_one_bash ); + + // But m2 has "expanded" set which the others do no + QVERIFY( *( m2.m_rootItem->child( 0 ) ) != *group ); +} + +void +ItemTests::testExampleFiles() +{ + QVERIFY( QStringLiteral( BUILD_AS_TEST ).endsWith( "/netinstall" ) ); + + QDir d( BUILD_AS_TEST ); + + for ( const QString& filename : QStringList { "netinstall.yaml" } ) + { + QFile f( d.filePath( filename ) ); + QVERIFY( f.exists() ); + QVERIFY( f.open( QIODevice::ReadOnly ) ); + QByteArray contents = f.readAll(); + QVERIFY( !contents.isEmpty() ); + + YAML::Node yamldoc = YAML::Load( contents.constData() ); + QVariantList yamlContents = Calamares::YAML::sequenceToVariant( yamldoc ); + + PackageModel m1( nullptr ); + m1.setupModelData( yamlContents ); + + // TODO: should test *something* about this file :/ + } +} + +void +ItemTests::testUrlFallback_data() +{ + QTest::addColumn< QString >( "filename" ); + QTest::addColumn< int >( "status" ); + QTest::addColumn< int >( "count" ); + + using S = Config::Status; + + QTest::newRow( "bad" ) << "1a-single-bad.conf" << smash( S::FailedBadConfiguration ) << 0; + QTest::newRow( "empty" ) << "1a-single-empty.conf" << smash( S::FailedNoData ) << 0; + QTest::newRow( "error" ) << "1a-single-error.conf" << smash( S::FailedBadData ) << 0; + QTest::newRow( "two" ) << "1b-single-small.conf" << smash( S::Ok ) << 2; + QTest::newRow( "five" ) << "1b-single-large.conf" << smash( S::Ok ) << 5; + QTest::newRow( "none" ) << "1c-none.conf" << smash( S::FailedNoData ) << 0; + QTest::newRow( "unset" ) << "1c-unset.conf" << smash( S::FailedNoData ) << 0; + // Finds small, then stops + QTest::newRow( "fallback-small" ) << "1d-fallback-small.conf" << smash( S::Ok ) << 2; + // Finds large, then stops + QTest::newRow( "fallback-large" ) << "1d-fallback-large.conf" << smash( S::Ok ) << 5; + // Finds empty, finds small + QTest::newRow( "fallback-mixed" ) << "1d-fallback-mixed.conf" << smash( S::Ok ) << 2; + // Finds empty, then bad + QTest::newRow( "fallback-bad" ) << "1d-fallback-bad.conf" << smash( S::FailedBadConfiguration ) << 0; +} + +void +ItemTests::testUrlFallback() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + QFETCH( QString, filename ); + QFETCH( int, status ); + QFETCH( int, count ); + + cDebug() << "Loading" << filename; + + // BUILD_AS_TEST is the source-directory path + QString testdir = QString( "%1/tests" ).arg( BUILD_AS_TEST ); + QFile fi( QString( "%1/%2" ).arg( testdir, filename ) ); + QVERIFY( fi.exists() ); + + Config c; + + QFile yamlFile( fi.fileName() ); + if ( yamlFile.exists() && yamlFile.open( QFile::ReadOnly | QFile::Text ) ) + { + QString ba( yamlFile.readAll() ); + QVERIFY( ba.length() > 0 ); + QHash< QString, QString > replace; + replace.insert( "TESTDIR", testdir ); + QString correctedDocument = KMacroExpander::expandMacros( ba, replace, '$' ); + + try + { + YAML::Node yamldoc = YAML::Load( correctedDocument.toUtf8() ); + auto map = Calamares::YAML::toVariant( yamldoc ).toMap(); + QVERIFY( map.count() > 0 ); + c.setConfigurationMap( map ); + } + catch ( YAML::Exception& ) + { + bool badYaml = true; + QVERIFY( !badYaml ); + } + } + else + { + QCOMPARE( QStringLiteral( "not found" ), fi.fileName() ); + } + + // Each of the configs sets required to **true**, which is not the default + QVERIFY( c.required() ); + + // Now give the loader time to complete + QEventLoop loop; + connect( &c, &Config::statusReady, &loop, &QEventLoop::quit ); + QSignalSpy spy( &c, &Config::statusReady ); + QTimer::singleShot( std::chrono::seconds( 1 ), &loop, &QEventLoop::quit ); + loop.exec(); + + // Check it didn't time out + QCOMPARE( spy.count(), 1 ); + // Check YAML-loading results + QCOMPARE( smash( c.statusCode() ), status ); + QCOMPARE( c.model()->rowCount(), count ); +} + +QTEST_GUILESS_MAIN( ItemTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/netinstall/groupstreeview.cpp b/calamares/src/modules/netinstall/groupstreeview.cpp new file mode 100644 index 0000000..f8b98eb --- /dev/null +++ b/calamares/src/modules/netinstall/groupstreeview.cpp @@ -0,0 +1,36 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "groupstreeview.h" + +#include "utils/Logger.h" + +#include + +void +GroupsTreeView::drawBranches( QPainter* painter, const QRect& rect, const QModelIndex& index ) const +{ + QTreeView::drawBranches( painter, rect, index ); + + // Empty names are handled specially: don't draw them as items, + // so the "branch" seems to just pass them by. + const QString s = index.data().toString(); + if ( s.isEmpty() ) + { +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + QStyleOptionViewItem opt = viewOptions(); +#else + QStyleOptionViewItem opt; + initViewItemOption( &opt ); +#endif + opt.state = QStyle::State_Sibling; + opt.rect = QRect( !isRightToLeft() ? rect.left() : rect.right() + 1, rect.top(), indentation(), rect.height() ); + painter->eraseRect( opt.rect ); + style()->drawPrimitive( QStyle::PE_IndicatorBranch, &opt, painter, this ); + } +} diff --git a/calamares/src/modules/netinstall/groupstreeview.h b/calamares/src/modules/netinstall/groupstreeview.h new file mode 100644 index 0000000..82f8bc0 --- /dev/null +++ b/calamares/src/modules/netinstall/groupstreeview.h @@ -0,0 +1,18 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include + +class GroupsTreeView : public QTreeView +{ +public: + using QTreeView::QTreeView; + +protected: + virtual void drawBranches( QPainter* painter, const QRect& rect, const QModelIndex& index ) const override; +}; diff --git a/calamares/src/modules/netinstall/netinstall.conf b/calamares/src/modules/netinstall/netinstall.conf new file mode 100644 index 0000000..f185fc1 --- /dev/null +++ b/calamares/src/modules/netinstall/netinstall.conf @@ -0,0 +1,347 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +### Netinstall module +# +# The netinstall module allows distribution maintainers to ship minimal ISOs +# with only a basic set of preinstalled packages. At installation time, the +# user is presented with the choice to install groups of packages from a +# predefined list. +# +# Calamares will then use the *packages* module to install the packages. +# Without a *packages* module in the exec phase somewhere **after** +# this netinstall, nothing will actually get installed. The packages +# module must be correctly configured **and** the package manager must +# be runnable from within the installed system at the point where it +# is invoked, otherwise you'll get nothing. +# +# There are two basic deployment schemes: +# - static package lists; the packages do not change for this release. +# In this case, the package list file may be on the ISO-image itself +# as a separate file, **or** included in this configuration file. +# Either will do; separate file is easier to update independently +# of the Calamares configuration, while merged configurations use +# fewer files overall and are closer to self-documenting. +# - online package lists; the package list is fetched from a remote +# URL and handled otherwise like a static list. This can be useful +# if the package list needs updating during the lifetime of an ISO- +# image, e.g. packages are added or renamed. +# +# There is only one required key for this module, *groupsUrl*. +# +# This module supports multiple instances through the *label* key, +# which allows you to distinguish them in the UI. +--- +# The *groupsUrl* determines where the data for the netinstall groups-and- +# packages comes from. The value of the key may be: +# +# - a single string (this is treated as a list with just that string in it) +# - a list of strings +# +# Each string is treated as a URL (see below for special cases. The +# list is examined **in order** and each URL is tried in turn. The +# first URL to load successfully -- even if it yields 0 packages -- +# ends the process. This allows using a network URL and a (fallback) +# local URL for package lists, or for using multiple mirrors of +# netinstall data. +# +# The URL must point to a YAML file that follows the format described +# below at the key *groups* -- except for the special case URL "local". +# Note that the contents of the groups file is the **important** +# part of the configuration of this module. It specifies what +# groups and packages the user may select (and so what commands are to +# be run to install them). +# +# The format of the groups file is the same as the format of the +# *groups* key described below, **except** that a stand-alone +# groups file does not have to have the top-level *groups* key. +# (It **may** have one, though, for instance when you copy +# this configuration file to `netinstall.yaml` and key *groups* +# must have a list-of-groups as value; if the file does not have +# a top-level key *groups*, then the file must contain only a list of groups. +# +# Each item in the list *groupsUrl* may be: +# - A remote URL like `http://example.org/netinstall.php` +# - A local file URL like `file:///usr/share/calamares/netinstall.yaml` +# - The special-case literal string `local` +# +# Non-special case URLs are loaded as YAML; if the load succeeds, then +# they are interpreted like the *groups* key below. The special case +# `local` loads the data directly from **this** file. +# +groupsUrl: local + +# Alternate form: +# groupsUrl: [ local ] + +# Net-based package list, with fallback to local file +# groupsUrl: +# - http://example.com/calamares/netinstall.yaml +# - file:///etc/calamares/modules/netinstall.yaml + + + +# If the installation can proceed without netinstall (e.g. the Live CD +# can create a working installed system, but netinstall is preferred +# to bring it up-to-date or extend functionality) leave this set to +# false (the default). If set to true, the netinstall data is required. +# +# This only has an effect if the netinstall data cannot be retrieved, +# or is corrupt: having "required" set, means the install cannot proceed. +# For local or file: type *groupsUrl* settings, this setting is not +# really meaningful. +required: false + +# To support multiple instances of this module, +# some strings are configurable and translatable here. +# Sub-keys under *label* are used for the user interface. +# - *sidebar* This is the name of the module in the progress-tree / sidebar +# in Calamares. +# - *title* This is displayed above the list of packages. +# If no *sidebar* values are provided, defaults to "Package selection" +# and existing translations. If no *title* values are provided, no string +# is displayed. +# +# Translations are handled through `[ll]` notation, much like in +# `.desktop` files. The string associated with `key[ll]` is used for +# *key* when when the language *ll* (language-code, like *nl* or *en_GB* +# or *ja*) is used. +# +# The following strings are **already** known to Calamares and can be +# listed here in *untranslated* form (e.g. as value of *sidebar*) +# without bothering with the translations: they are picked up from +# the regular translation framework: +# - "Package selection" +# - "Office software" +# - "Office package" +# - "Browser software" +# - "Browser package" +# - "Web browser" +# - "Kernel" +# - "Services" +# - "Login" +# - "Desktop" +# - "Applications" +# - "Communication" +# - "Development" +# - "Office" +# - "Multimedia" +# - "Internet" +# - "Theming" +# - "Gaming" +# - "Utilities" +# Other strings should follow the translations format. +label: + sidebar: "Package selection" + # sidebar[nl]: "Pakketkeuze" + # sidebar[en_GB]: "Package choice" + # sidebar[ja]: "知りません" # "I don't know" + # title: "Office Package" + # title[nl]: "Kantoorsoftware" + +# If, and only if, *groupsUrl* is set to the literal string `local`, +# groups data is read from this file. The value of *groups* must be +# a list. Each item in the list is a group (of packages, or subgroups, +# or both). A standalone groups file contains just the list, +# (without the top-level *groups* key, or just the top-level *groups* +# key and with the list as its value, like in this file). +# +# Using `local` is recommended only for small static package lists. +# Here it is used for documentation purposes. +# +# +### Groups Format +# +# Each item in the list describes one group. The following keys are +# required for each group: +# +# - *name* of the group; short and human-readable. Shown in the first +# column of the UI. +# - *description* of the group; longer and human-readable. Shown in the +# second column of the UI. This is one of the things that visually +# distinguishes groups (with descriptions) from packages (without). +# - *packages*, a list of packages that belong to this group. +# The items of the *packages* list are actual package names +# as passed to the package manager (e.g. `qt5-creator-dev`). +# This list may be empty (e.g. if your group contains only +# subgroups). This key isn't **really** required, either -- +# one of *subgroups* or *packages* is. +# +# The following keys are **optional** for a group: +# +# - *hidden*: if true, do not show the group on the page. Defaults to false. +# - *selected*: if true, display the group as selected. Defaults to the +# parent group's value, if there is a parent group; top-level groups +# are set to true by default. +# - *critical*: if true, make the installation process fail if installing +# any of the packages in the group fails. Otherwise, just log a warning. +# Defaults to false. If not set in a subgroup (see below), inherits from +# the parent group. +# - *immutable*: if true, the state of the group (and all its subgroups) +# cannot be changed; no packages can be selected or deselected. No +# checkboxes are show for the group. Setting *immutable* to true +# really only makes sense in combination with *selected* set to true, +# so that the packages will be installed. (Setting a group to immutable +# can be seen as removing it from the user-interface.) +# - *noncheckable*: if true, the entire group cannot be selected or +# deselected by a single click. This does not affect any subgroups +# or child packages +# - *expanded*: if true, the group is shown in an expanded form (that is, +# not-collapsed) in the treeview on start. This only affects the user- +# interface. Only top-level groups are show expanded-initially. +# - *subgroups*: if present this follows the same structure as the top level +# groups, allowing sub-groups of packages to an arbitary depth. +# - *pre-install*: an optional command to run within the new system before +# the group's packages are installed. It will run before **each** package in +# the group is installed. +# - *post-install*: an optional command to run within the new system after +# the group's packages are installed. It will run after **each** package in +# the group is installed. +# +# If you set both *hidden* and *selected* for a top-level group, you are +# creating a "default" group of packages which will always be installed +# in the user's system. Hidden selected subgroups are installed if their +# parent is selected. Setting *hidden* to true without *selected*, or with +# *selected* set to false, is kind of pointless and will generate a warning. +# +# The *pre-install* and *post-install* commands are **not** passed to +# a shell; see the **packages** module configuration (i.e. `packages.conf`) +# for details. To use a full shell pipeline, call the shell explicitly. +# +# Non-critical groups are installed by calling the package manager +# individually, once for each package (and ignoring errors), while +# critical packages are installed in one single call to the package +# manager (and errors cause the installation to terminate). +# +# +# +# The *groups* key below contains some common patterns for packages +# and sub-groups, with documentation. + + +groups: + # This group is hidden, so the name and description are not really + # important. Since it is selected, these packages will be installed. + # It's non-critical, so they are installed one-by-one. + # + # This is a good approach for something you want up-to-date installed + # in the target system every time. + - name: "Default" + description: "Default group" + hidden: true + selected: true + critical: false + packages: + - base + - chakra-live-skel + # The Shells group contains only subgroups, no packages itself. + # The *critical* value is set for the subgroups that do not + # override it; *selected* is set to false but because one of + # the subgroups sets *selected* to true, the overall state of + # **this** group is partly-selected. + # + # Each of the sub-groups lists a bunch of packages that can + # be individually selected, so a user can pick (for instance) + # just one of the ZSH packages if they like. + - name: "Shells" + description: "Shells" + hidden: false + selected: false + critical: true + subgroups: + - name: "Bash" + description: "Bourne Again Shell" + selected: true + packages: + - bash + - bash-completion + - name: "Zsh" + description: "Zee shell, boss" + packages: + - zsh + - zsh-completion + - zsh-extensions + # The kernel group has no checkbox, because it is immutable. + # It can be (manually) expanded, and the packages inside it + # will be shown, also without checkboxes. This is a way to + # inform users that something will always be installed, + # sort of like a hidden+selected group but visible. + - name: "Kernel" + description: "Kernel bits" + hidden: false + selected: true + critical: true + immutable: true + packages: + - kernel + - kernel-debugsym + - kernel-nvidia + # *selected* defaults to true for top-level + - name: Communications + description: "Communications Software" + packages: + - ruqola + - konversation + - nheko + - quaternion + # Setting *selected* is supported. Here we also show off "rich" + # packages: ones with a package-name (for the package-manager) + # and a description (for the human). + - name: Editors + description: "Editing" + selected: false + packages: + - vi + - emacs + - nano + - name: kate-git + description: Kate (unstable) + - name: kate + description: KDE's text editor + # The "bare" package names can be intimidating, so you can use subgroups + # to provide human-readable names while hiding the packages themselves. + # This also allows you you group related packages -- suppose you feel + # that KDevelop should be installed always with PHP and Python support, + # but that support is split into multiple packages. + # + # So this subgroup (IDE) contains subgroups, one for each "package" + # we want to install. Each of those subgroups (Emacs, KDevelop) + # in turn contains **one** bogus subgroup, which then has the list + # of relevant packages. This extra-level-of-subgrouping allows us + # to list packages, while giving human-readable names. + # + # The name of the internal subgroup doesn't matter -- it is hidden + # from the user -- so we can give them all bogus names and + # descriptions, even the same name. Here, we use "Bogus". You + # can re-use the subgroup name, it doesn't really matter. + # + # Each internal subgroup is set to *hidden*, so it does not show up + # as an entry in the list, and it is set to *selected*, + # so that if you select its parent subgroup, the packages from + # the subgroup are selected with it and get installed. + - name: IDE + description: "Development Environment" + selected: false + subgroups: + - name: Emacs + description: LISP environment and editor + subgroups: + - name: Bogus + description: Bogus + hidden: true + selected: true + packages: + - emacs + - name: KDevelop + description: KDE's C++, PHP and Python environment + subgroups: + - name: Bogus + description: Bogus + hidden: true + selected: true + packages: + - kdevelop + - kdevelop-dev + - kdev-php + - kdev-python + diff --git a/calamares/src/modules/netinstall/netinstall.schema.yaml b/calamares/src/modules/netinstall/netinstall.schema.yaml new file mode 100644 index 0000000..1faf656 --- /dev/null +++ b/calamares/src/modules/netinstall/netinstall.schema.yaml @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-FileContributor: benne-dee ( worked on groups schema ) +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/draft-07/schema# +$id: https://calamares.io/schemas/netinstall +definitions: + package: + oneOf: + - + type: string + description: bare package - actual package name as passed to the package manager + (e.g. `qt5-creator-dev`). + - + type: object + description: rich package - one with a package-name (for the package-manager) and + a description (for the human). + properties: + name: { type: string } + description: { type: string } + group: + type: object + description: Longer discussion in `netinstall.conf` file under 'Groups Format' + properties: + name: { type: string } + description: { type: string } + packages: + type: array + items: { $ref: '#/definitions/package' } + hidden: { type: boolean, default: false } + selected: { type: boolean } + critical: { type: boolean, default: false } + immutable: { type: boolean } + noncheckable: { type: boolean } + expanded: { type: boolean } + subgroups: + type: array + items: { $ref: '#/definitions/group' } + pre-install: + type: string + description: an optional command to run within the new system before the group's + packages are installed. It will run before **each** package in the group + is installed. + post-install: + type: string + description: an optional command to run within the new system after the group's + packages are installed. It will run after **each** package in the group + is installed. + required: [name, description] # Always required, at any level in the subgroups hirearchy + if: + properties: + subgroups: + maxItems: 0 + then: + required: [name, description, packages] # bottom-most (sub)group requires some package (otherwise, why bother?) + groups: + type: array + items: { $ref: '#/definitions/group' } + +oneOf: +- # netinstall.conf + type: object + description: netinstall.conf schema + additionalProperties: false + properties: + groupsUrl: { type: string } + required: { type: boolean, default: false } + label: # Translatable labels + type: object + additionalProperties: true + properties: + sidebar: { type: string } + title: { type: string } + groups: { $ref: '#/definitions/groups' } + required: [ groupsUrl ] + +- # Groups file with top level *groups* key + type: object + description: Groups file with top level *groups* key + additionalProperties: false + properties: + groups: { $ref: '#/definitions/groups' } + required: [ groups ] + +- # Groups file bare + { $ref: '#/definitions/groups' } diff --git a/calamares/src/modules/netinstall/netinstall.yaml b/calamares/src/modules/netinstall/netinstall.yaml new file mode 100644 index 0000000..e55bc1b --- /dev/null +++ b/calamares/src/modules/netinstall/netinstall.yaml @@ -0,0 +1,218 @@ +# Example configuration with groups and packages, taken from Chakra Linux. +# +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# This example is rather limited. See `netinstall.conf` for full documentation +# on the configuration format. The module configuration file `netinstall.conf` +# **may** contain the package-list, but that is only useful for static +# package-lists. If you are updating the package lists or changing the package +# offerings, an online `netinstall.yaml` is the way to go; or you can put the +# `netinstall.yaml` on the installation media and modify that, while keeping +# your Calamares configuration constant. +# +# Which approach works depends on what you want to offer and how your +# ISO-image toolchain works. +- name: "Default" + description: "Default group" + hidden: true + selected: true + critical: false + packages: + - base + - chakra-live-skel + - cdemu-client + - lsb-release + - avahi + - grub + # disk utils + - dosfstools + - e2fsprogs + - fuse + - gptfdisk + - jfsutils + - ntfs-3g + - reiserfsprogs + - xfsprogs + # power + - acpi_call + - pmtools + # network + - dnsutils + - iputils + - netcfg + - xinetd + # firmwares + - alsa-firmware + - linux-firmware + # sound + - alsa-lib + - alsa-utils + - gstreamer + - gst-plugins-good + - gst-plugins-bad + - libao + - libcanberra-gstreamer + - libcanberra-pulse + - pulseaudio + - pulseaudio-alsa + # tools + - bash-completion + - hwinfo + - lsof + - man-db + - mlocate + - nano + - openssh + - sudo + - vim + - zsh # :D + # archivers + - p7zip + - unarj + - unrar + - unzip + - zip + # xorg base + - xorg + - xorg-apps + - xorg-fonts-alias + - xorg-fonts-encodings + - xorg-fonts-misc + - xorg-res-utils + - xorg-server + - xorg-server-utils + - xorg-xauth + - xorg-xinit + - xorg-xkb-utils + # xorg video drivers + - xf86-video-apm + - xf86-video-ark + - xf86-video-ati + - xf86-video-chips + - xf86-video-cirrus + - xf86-video-glint + - xf86-video-i128 + - xf86-video-i740 + - xf86-video-intel + - xf86-video-mach64 + - xf86-video-mga + - xf86-video-neomagic + - xf86-video-nouveau + - xf86-video-nv + - xf86-video-openchrome + - xf86-video-r128 + - xf86-video-rendition + - xf86-video-s3 + - xf86-video-s3virge + - xf86-video-savage + - xf86-video-siliconmotion + - xf86-video-sisusb + - xf86-video-tdfx + - xf86-video-trident + - xf86-video-tseng + - xf86-video-v4l + - xf86-video-vesa + - xf86-video-voodoo + - mesa-libgl + # xorg input drivers + - xf86-input-synaptics + - xf86-input-wacom + - xf86-input-evdev + - xf86-input-keyboard + - xf86-input-mouse + # fonts + - terminus-font + - ttf-dejavu + - ttf-liberation + - wqy-microhei + - xorg-fonts-100dpi + - xorg-fonts-75dpi + - xorg-fonts-cyrillic + # additional stuff that needs xorg + - hicolor-icon-theme + # kde + - chakra-common + - qt + - kde-baseapps + - kde-baseapps-dolphin + - kde-baseapps-konsole + - kde-runtime + - kde-workspace + - kdelibs + - kdepimlibs + - kdemultimedia-kmix + - oxygen-icons + - phonon-backend-gstreamer + # chakra theme (including kapudan options) + - chakra-wallpapers-dharma + - chakra-wallpapers-curie + - chakra-wallpapers-descartes + - grub2-themes-sirius + - kapudan-kde-themes-caledonia + - kde-kdm-themes-sirius + - kde-ksplash-themes-sirius + - kde-plasma-themes-caledonia + - python2-imaging + - python2-v4l2capture + - python2-xlib + - caledonia-colors + - yakuake-themes-ronak + # kde (everything else) + - kdeadmin-kcron + - kdeadmin-kuser + - kdeplasma-addons-applets-icontasks + - kdesdk-kate + - kdeutils-ark + - kdeutils-kgpg + - kdeutils-sweeper + # kde network + - kdeplasma-applets-plasma-nm + - networkmanager-dispatcher-ntpd + - kcm-ufw + # applications + - rekonq + - yakuake + # enable systemd-units + - chakra-init-live + # overlay pkgs + - partitionmanager + - octopi-notifier + - kapudan +- name: "Wireless" + description: "Tools for wireless connections" + critical: false + packages: + - crda + - ndiswrapper + - usb-modeswitch + - wireless-regdb + - wireless_tools + - wpa_supplicant +- name: "CCR" + description: "Tools for the Chakra Community Repository" + packages: + - ccr + - base-devel +- name: "Graphics" + description: "Applications to work with graphics" + packages: + - kdegraphics-gwenview + - kdegraphics-kamera + - kdegraphics-kcolorchooser + - kdegraphics-kgamma + - kdegraphics-kolourpaint + - kdegraphics-kruler + - kdegraphics-ksaneplugin + - kdegraphics-ksnapshot + - kdegraphics-libkdcraw + - kdegraphics-libkexiv2 + - kdegraphics-libkipi + - kdegraphics-libksane + - kdegraphics-mobipocket + - kdegraphics-okular + - kdegraphics-strigi-analyzer + - kdegraphics-svgpart + - kdegraphics-thumbnailers + - imagemagick + diff --git a/calamares/src/modules/netinstall/page_netinst.ui b/calamares/src/modules/netinstall/page_netinst.ui new file mode 100644 index 0000000..dd87ef7 --- /dev/null +++ b/calamares/src/modules/netinstall/page_netinst.ui @@ -0,0 +1,75 @@ + + + +SPDX-FileCopyrightText: 2016 shainer <syn.shainer@gmail.com> +SPDX-License-Identifier: GPL-3.0-or-later + + Page_NetInst + + + + 0 + 0 + 997 + 474 + + + + + + + + + + Qt::AlignCenter + + + + + + + + 16777215 + 16777215 + + + + true + + + + + 0 + 0 + 981 + 410 + + + + + 11 + + + + + + + + + + + + + + + + + + GroupsTreeView + QTreeView +
groupstreeview.h
+
+
+ + +
diff --git a/calamares/src/modules/netinstall/tests/1a-single-bad.conf b/calamares/src/modules/netinstall/tests/1a-single-bad.conf new file mode 100644 index 0000000..c08d387 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1a-single-bad.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/bad.yaml diff --git a/calamares/src/modules/netinstall/tests/1a-single-empty.conf b/calamares/src/modules/netinstall/tests/1a-single-empty.conf new file mode 100644 index 0000000..2444a04 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1a-single-empty.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-empty.yaml diff --git a/calamares/src/modules/netinstall/tests/1a-single-error.conf b/calamares/src/modules/netinstall/tests/1a-single-error.conf new file mode 100644 index 0000000..a602b17 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1a-single-error.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-error.yaml diff --git a/calamares/src/modules/netinstall/tests/1b-single-large.conf b/calamares/src/modules/netinstall/tests/1b-single-large.conf new file mode 100644 index 0000000..eee67e6 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1b-single-large.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-large.yaml diff --git a/calamares/src/modules/netinstall/tests/1b-single-small.conf b/calamares/src/modules/netinstall/tests/1b-single-small.conf new file mode 100644 index 0000000..2de9b4d --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1b-single-small.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-small.yaml diff --git a/calamares/src/modules/netinstall/tests/1c-none.conf b/calamares/src/modules/netinstall/tests/1c-none.conf new file mode 100644 index 0000000..e0f097d --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1c-none.conf @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: [] diff --git a/calamares/src/modules/netinstall/tests/1c-unset.conf b/calamares/src/modules/netinstall/tests/1c-unset.conf new file mode 100644 index 0000000..b25dbb6 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1c-unset.conf @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true diff --git a/calamares/src/modules/netinstall/tests/1d-fallback-bad.conf b/calamares/src/modules/netinstall/tests/1d-fallback-bad.conf new file mode 100644 index 0000000..1a36f78 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1d-fallback-bad.conf @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-nonexistent.yaml + - file://$TESTDIR/data-empty.yaml + - file://$TESTDIR/data-empty.yaml + - file://$TESTDIR/data-bad.yaml diff --git a/calamares/src/modules/netinstall/tests/1d-fallback-large.conf b/calamares/src/modules/netinstall/tests/1d-fallback-large.conf new file mode 100644 index 0000000..5abb05c --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1d-fallback-large.conf @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-nonexistent.yaml + - file://$TESTDIR/data-bad.yaml + - file://$TESTDIR/data-large.yaml + - file://$TESTDIR/data-small.yaml diff --git a/calamares/src/modules/netinstall/tests/1d-fallback-mixed.conf b/calamares/src/modules/netinstall/tests/1d-fallback-mixed.conf new file mode 100644 index 0000000..79cf677 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1d-fallback-mixed.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-nonexistent.yaml + - file://$TESTDIR/data-empty.yaml + - file://$TESTDIR/data-bad.yaml + - file://$TESTDIR/data-empty.yaml + - file://$TESTDIR/data-small.yaml + - file://$TESTDIR/data-large.yaml + - file://$TESTDIR/data-bad.yaml diff --git a/calamares/src/modules/netinstall/tests/1d-fallback-small.conf b/calamares/src/modules/netinstall/tests/1d-fallback-small.conf new file mode 100644 index 0000000..e38a7d6 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/1d-fallback-small.conf @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +required: true +groupsUrl: + - file://$TESTDIR/data-nonexistent.yaml + - file://$TESTDIR/data-bad.yaml + - file://$TESTDIR/data-small.yaml + - file://$TESTDIR/data-large.yaml diff --git a/calamares/src/modules/netinstall/tests/data-empty.yaml b/calamares/src/modules/netinstall/tests/data-empty.yaml new file mode 100644 index 0000000..065a0a0 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/data-empty.yaml @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +bogus: true + diff --git a/calamares/src/modules/netinstall/tests/data-error.yaml b/calamares/src/modules/netinstall/tests/data-error.yaml new file mode 100644 index 0000000..1445f89 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/data-error.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +derp +derp +herpa-derp: no +-- +# This file is not valid YAML diff --git a/calamares/src/modules/netinstall/tests/data-large.yaml b/calamares/src/modules/netinstall/tests/data-large.yaml new file mode 100644 index 0000000..7b47aa3 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/data-large.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +- name: "Default" + description: "Default group" + hidden: false + selected: true + critical: false + packages: + - base +- name: "Two" + description: "group 2" + hidden: false + selected: true + critical: false + packages: + - chakra-live-two +- name: "Three" + description: "group 3" + hidden: false + selected: true + critical: false + packages: + - chakra-live-three +- name: "Four" + description: "group 4" + hidden: false + selected: true + critical: false + packages: + - chakra-live-four +- name: "Five" + description: "group 5" + hidden: false + selected: true + critical: false + packages: + - chakra-live-five diff --git a/calamares/src/modules/netinstall/tests/data-small.yaml b/calamares/src/modules/netinstall/tests/data-small.yaml new file mode 100644 index 0000000..6554cf7 --- /dev/null +++ b/calamares/src/modules/netinstall/tests/data-small.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +- name: "Default" + description: "Default group" + hidden: false + selected: true + critical: false + packages: + - base +- name: "Second" + description: "Second group" + hidden: false + selected: true + critical: false + packages: + - chakra-live-skel diff --git a/calamares/src/modules/networkcfg/main.py b/calamares/src/modules/networkcfg/main.py new file mode 100644 index 0000000..efe6930 --- /dev/null +++ b/calamares/src/modules/networkcfg/main.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Philip Müller +# SPDX-FileCopyrightText: 2014 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-FileCopyrightText: 2021 Anke boersma +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import os +import glob +import shutil + +import libcalamares + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Saving network configuration.") + + +def get_live_user(): + """ + Gets the "live user" login. This might be "live", or "nitrux", + or something similar: it is the login name used *right now*, + and network configurations saved for that user, should be applied + also for the installed user (which probably has a different name). + """ + # getlogin() is a thin-wrapper, and depends on getlogin(3), + # which reads utmp -- and utmp isn't always set up right. + try: + return os.getlogin() + except OSError: + pass + # getpass will return the **current** user, which is generally root. + # That isn't very useful, because the network settings have been + # made outside of Calamares-running-as-root, as a different user. + # + # If Calamares is running as non-root, though, this is fine. + import getpass + name = getpass.getuser() + if name != "root": + return name + + # TODO: other mechanisms, e.g. guessing that "live" is the name + # TODO: support a what-is-the-live-user setting + return None + + +def replace_username(nm_config_filename, live_user, target_user): + """ + If @p live_user isn't None, then go through the given + file and replace @p live_user by the @p target_user. + + Reads the file, then (re-)writes it with new permissions lives. + """ + # FIXME: Perhaps if live_user is None, we should just replace **all** + # permissions lines? After all, this is supposed to be a live + # system so **whatever** NM networks are configured, should be + # available to the new user. + if live_user is None: + return + if not os.path.exists(nm_config_filename): + return + + with open(nm_config_filename, "r", encoding="UTF-8") as network_conf: + text = network_conf.readlines() + + live_permissions = 'permissions=user:{}:;'.format(live_user) + target_permissions = 'permissions=user:{}:;\n'.format(target_user) + with open(nm_config_filename, "w", encoding="UTF-8") as network_conf: + for line in text: + if live_permissions in line: + line = target_permissions + network_conf.write(line) + + +def path_pair(root_mount_point, relative_path): + """ + Returns /relative_path and the relative path in the target system. + """ + return ("/" + relative_path, os.path.join(root_mount_point, relative_path)) + + +def run(): + """ + Setup network configuration + """ + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + user = libcalamares.globalstorage.value("username") + live_user = get_live_user() + + if root_mount_point is None: + libcalamares.utils.warning("rootMountPoint is empty, {!s}".format(root_mount_point)) + return (_("Configuration Error"), + _("No root mount point is given for
{!s}
to use." ).format("networkcfg")) + + source_nm, target_nm = path_pair(root_mount_point, "etc/NetworkManager/system-connections/") + + # Sanity checks. We don't want to do anything if a network + # configuration already exists on the target + if os.path.exists(source_nm) and os.path.exists(target_nm): + for network in os.listdir(source_nm): + # Skip LTSP live + if network == "LTSP": + continue + + source_network = os.path.join(source_nm, network) + target_network = os.path.join(target_nm, network) + + if os.path.exists(target_network): + continue + + try: + shutil.copy(source_network, target_network, follow_symlinks=False) + replace_username(target_network, live_user, user) + except FileNotFoundError: + libcalamares.utils.debug( + "Can't copy network configuration files in {}".format(source_network) + ) + except FileExistsError: + pass + + # Also install netplan files + source_netplan = "/etc/netplan" + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + target_netplan = os.path.join(root_mount_point, source_netplan.lstrip('/')) + + if os.path.exists(source_netplan) and os.path.exists(target_netplan): + # Set NetworkManager to be the default renderer if Netplan is installed + # TODO: We might rather do that inside the network-manager package, see: + # https://bugs.launchpad.net/ubuntu/+source/ubuntu-settings/+bug/2020110 + default_renderer = os.path.join(root_mount_point, "usr/lib/netplan", + "00-network-manager-all.yaml") + if not os.path.exists(default_renderer): + renderer_file = os.path.join(target_netplan, + "01-network-manager-all.yaml") + nm_renderer = """# This file was written by calamares. +# Let NetworkManager manage all devices on this system. +# For more information, see netplan(5). +network: + version: 2 + renderer: NetworkManager +""" + with open(renderer_file, 'w') as f: + f.writelines(nm_renderer) + os.chmod(f.fileno(), 0o600) + + # Copy existing Netplan configuration + for cfg in glob.glob(os.path.join(source_netplan, "*.yaml")): + source_cfg = os.path.join(source_netplan, cfg) + target_cfg = os.path.join(target_netplan, os.path.basename(cfg)) + + if os.path.exists(target_cfg): + continue + + shutil.copy(source_cfg, target_cfg) + + # We need to overwrite the default resolv.conf in the chroot. + source_resolv, target_resolv = path_pair(root_mount_point, "etc/resolv.conf") + if source_resolv != target_resolv and os.path.exists(source_resolv): + try: + os.remove(target_resolv) + except Exception as err: + libcalamares.utils.debug( + "Couldn't remove {}: {}".format(target_resolv, err) + ) + + try: + shutil.copy(source_resolv, target_resolv, follow_symlinks=False) + except Exception as err: + libcalamares.utils.debug( + "Can't copy resolv.conf from {}: {}".format(source_resolv, err) + ) + + return None diff --git a/calamares/src/modules/networkcfg/module.desc b/calamares/src/modules/networkcfg/module.desc new file mode 100644 index 0000000..cbafe8c --- /dev/null +++ b/calamares/src/modules/networkcfg/module.desc @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "networkcfg" +interface: "python" +script: "main.py" +noconfig: true diff --git a/calamares/src/modules/notesqml/CMakeLists.txt b/calamares/src/modules/notesqml/CMakeLists.txt new file mode 100644 index 0000000..c76ab51 --- /dev/null +++ b/calamares/src/modules/notesqml/CMakeLists.txt @@ -0,0 +1,19 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +if(NOT WITH_QML) + calamares_skip_module( "notesqml (QML is not supported in this build)" ) + return() +endif() + +calamares_add_plugin(notesqml + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + NotesQmlViewStep.cpp + RESOURCES + notesqml.qrc + SHARED_LIB +) diff --git a/calamares/src/modules/notesqml/NotesQmlViewStep.cpp b/calamares/src/modules/notesqml/NotesQmlViewStep.cpp new file mode 100644 index 0000000..ff346bd --- /dev/null +++ b/calamares/src/modules/notesqml/NotesQmlViewStep.cpp @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "NotesQmlViewStep.h" + +#include + +NotesQmlViewStep::NotesQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) +{ +} + +NotesQmlViewStep::~NotesQmlViewStep() {} + +QString +NotesQmlViewStep::prettyName() const +{ + return m_notesName ? m_notesName->get() : tr( "Notes" ); +} + +void +NotesQmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + bool qmlLabel_ok = false; + auto qmlLabel = Calamares::getSubMap( configurationMap, "qmlLabel", qmlLabel_ok ); + + if ( qmlLabel.contains( "notes" ) ) + { + m_notesName = new Calamares::Locale::TranslatedString( qmlLabel, "notes" ); + } + + Calamares::QmlViewStep::setConfigurationMap( configurationMap ); // call parent implementation last +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( NotesQmlViewStepFactory, registerPlugin< NotesQmlViewStep >(); ) diff --git a/calamares/src/modules/notesqml/NotesQmlViewStep.h b/calamares/src/modules/notesqml/NotesQmlViewStep.h new file mode 100644 index 0000000..02a0332 --- /dev/null +++ b/calamares/src/modules/notesqml/NotesQmlViewStep.h @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#ifndef NOTESQMLVIEWSTEP_H +#define NOTESQMLVIEWSTEP_H + +#include "DllMacro.h" +#include "locale/TranslatableConfiguration.h" +#include "utils/PluginFactory.h" +#include "utils/System.h" +#include "utils/Variant.h" +#include "viewpages/QmlViewStep.h" + +class PLUGINDLLEXPORT NotesQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + NotesQmlViewStep( QObject* parent = nullptr ); + ~NotesQmlViewStep() override; + + QString prettyName() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + Calamares::Locale::TranslatedString* m_notesName; // As it appears in the sidebar +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( NotesQmlViewStepFactory ) + +#endif diff --git a/calamares/src/modules/notesqml/examples/notesqml.qml.example b/calamares/src/modules/notesqml/examples/notesqml.qml.example new file mode 100644 index 0000000..782ae40 --- /dev/null +++ b/calamares/src/modules/notesqml/examples/notesqml.qml.example @@ -0,0 +1,74 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Anke Boersma + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +/* Some Calamares internals are available to all QML modules. + * They live in the calamares.ui namespace (filled programmatically + * by Calamares). One of the internals that is exposed is the + * Branding object, which can be used to retrieve strings and paths + * and colors. + */ +import calamares.ui 1.0 + + import QtQuick 2.7 import QtQuick.Controls 2.2 import QtQuick.Window 2.2 import QtQuick.Layouts 1.3 import QtQuick.Controls.Material 2.1 + + Item +{ +width: + 740 height : 420 + + Flickable + { + id: + flick anchors + .fill : parent contentHeight : 800 + + ScrollBar.vertical : ScrollBar { id : fscrollbar width : 10 policy : ScrollBar.AlwaysOn } + + TextArea + { + id: + intro + x: 1 + y: 0 +width: + parent.width - fscrollbar.width + font.pointSize: 14 +textFormat: + Text.RichText +antialiasing: + true +activeFocusOnPress: + false +wrapMode: + Text.WordWrap + +text: + qsTr( "

%1

+

This an example QML file, showing options in RichText with Flickable content.

+ +

QML with RichText can use HTML tags, Flickable content is useful for touchscreens.

+ +

This is bold text

+

This is italic text

+

This is underlined text

+

This text will be center-aligned.

+

This is strikethrough

+ +

Code example: + ls -l /home

+ +

Lists:

+
    +
  • Intel CPU systems
  • +
  • AMD CPU systems
  • +
+ +

The vertical scrollbar is adjustable, current width set to 10.

").arg(Branding.string(Branding.VersionedName)) + } + } +} diff --git a/calamares/src/modules/notesqml/notesqml.conf b/calamares/src/modules/notesqml/notesqml.conf new file mode 100644 index 0000000..c65f988 --- /dev/null +++ b/calamares/src/modules/notesqml/notesqml.conf @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# The *notesqml* module can be used to display a QML file +# as an installer step. This is most useful for release-notes +# and similar somewhat-static content, but if you want to you +# can put SameGame in there as well. +# +# While the module compiles a QML file into a QRC for inclusion +# into the shared library, normal use will configure it with +# an external file, either from Calamares AppData directory or +# from the branding directory. +# +# --- +# +# QML modules can search for the QML inside the Qt resources +# (QRC) which are compiled into the module, or in the branding +# setup for Calamares, (or both of them, with branding taking +# precedence). This allows the module to ship a default UI and +# branding to optionally introduce a replacement file. +# +# Generally, leave the search method set to "both" because if +# you don't want to brand the UI, just don't ship a branding +# QML file for it. +# +# To support instanced QML modules, searches in the branding +# directory look for the full notesqml@instanceid name as well. +--- +# Search mode. Valid values are "both", "qrc" and "branding" +qmlSearch: both + +# Name of the QML file. If not set, uses the name of the instance +# of the module (e.g. if you list this module in `settings.conf` +# in the *instances* section, you get *id*, otherwise it would +# normally be "notesqml"). +# qmlFilename: notesqml + +# This is the name of the module in the progress-tree / sidebar +# in Calamares. To support multiple instances of the QML module, +# the name is configurable and translatable here. +qmlLabel: + notes: "Release Notes" + notes[nl]: "Opmerkingen" diff --git a/calamares/src/modules/notesqml/notesqml.qml b/calamares/src/modules/notesqml/notesqml.qml new file mode 100644 index 0000000..7805f27 --- /dev/null +++ b/calamares/src/modules/notesqml/notesqml.qml @@ -0,0 +1,56 @@ +/* === This file is part of Calamares - === + * + * Copyright 2020, Anke Boersma + * Copyright 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +/* Some Calamares internals are available to all QML modules. + * They live in the io.calamares namespace (filled programmatically + * by Calamares). One of the internals that is exposed in the sub- + * namespace io.calamares.ui is the Branding object, which can be used + * to retrieve strings and paths and colors. For a full list, see + * the documentation in `Qml.h`. + */ +import io.calamares.ui 1.0 + +import QtQuick 2.7 +import QtQuick.Controls 2.2 +import QtQuick.Window 2.2 +import QtQuick.Layouts 1.3 +import QtQuick.Controls.Material 2.1 + +Item { + width: 740 + height: 420 + + Flickable { + id: flick + anchors.fill: parent + contentHeight: 800 + + ScrollBar.vertical: ScrollBar { + id: fscrollbar + width: 10 + policy: ScrollBar.AlwaysOn + } + + TextArea { + id: intro + x: 1 + y: 0 + width: parent.width - fscrollbar.width + font.pointSize: 14 + textFormat: Text.RichText + antialiasing: true + activeFocusOnPress: false + wrapMode: Text.WordWrap + + text: qsTr("

%1

+

These are example release notes.

" + ).arg(Branding.string(Branding.VersionedName)) + + } + } +} diff --git a/calamares/src/modules/notesqml/notesqml.qrc b/calamares/src/modules/notesqml/notesqml.qrc new file mode 100644 index 0000000..a4aa190 --- /dev/null +++ b/calamares/src/modules/notesqml/notesqml.qrc @@ -0,0 +1,5 @@ + + + notesqml.qml + + diff --git a/calamares/src/modules/oemid/CMakeLists.txt b/calamares/src/modules/oemid/CMakeLists.txt new file mode 100644 index 0000000..d7c35bb --- /dev/null +++ b/calamares/src/modules/oemid/CMakeLists.txt @@ -0,0 +1,17 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(oemid + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + IDJob.cpp + OEMViewStep.cpp + UI + OEMPage.ui + LINK_PRIVATE_LIBRARIES + ${qtname}::Widgets + SHARED_LIB +) diff --git a/calamares/src/modules/oemid/IDJob.cpp b/calamares/src/modules/oemid/IDJob.cpp new file mode 100644 index 0000000..e673f7c --- /dev/null +++ b/calamares/src/modules/oemid/IDJob.cpp @@ -0,0 +1,89 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "IDJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" + +#include "utils/Logger.h" + +#include +#include + +IDJob::IDJob( const QString& id, QObject* parent ) + : Job( parent ) + , m_batchIdentifier( id ) +{ +} + +QString +IDJob::prettyName() const +{ + return tr( "OEM Batch Identifier" ); +} + +Calamares::JobResult +IDJob::writeId( const QString& dirs, const QString& filename, const QString& contents ) +{ + if ( !QDir().mkpath( dirs ) ) + { + cError() << "Could not create directories" << dirs; + return Calamares::JobResult::error( tr( "OEM Batch Identifier" ), + tr( "Could not create directories %1." ).arg( dirs ) ); + } + + QFile output( QDir( dirs ).filePath( filename ) ); + if ( output.exists() ) + { + cWarning() << "Existing OEM Batch ID" << output.fileName() << "overwritten."; + } + + if ( !output.open( QIODevice::WriteOnly ) ) + { + cError() << "Could not write to" << output.fileName(); + return Calamares::JobResult::error( tr( "OEM Batch Identifier" ), + tr( "Could not open file %1." ).arg( output.fileName() ) ); + } + + if ( output.write( contents.toUtf8() ) < 0 ) + { + cError() << "Write error on" << output.fileName(); + return Calamares::JobResult::error( tr( "OEM Batch Identifier" ), + tr( "Could not write to file %1." ).arg( output.fileName() ) ); + } + output.write( "\n" ); // Ignore error on this one + + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +IDJob::exec() +{ + cDebug() << "Setting OEM Batch ID to" << m_batchIdentifier; + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + QString targetDir = QStringLiteral( "/var/log/installer/" ); + QString targetFile = QStringLiteral( "oem-id" ); + QString rootMount = gs->value( "rootMountPoint" ).toString(); + + // Don't bother translating internal errors + if ( rootMount.isEmpty() && Calamares::Settings::instance()->doChroot() ) + { + return Calamares::JobResult::internalError( "OEM Batch Identifier", + "No rootMountPoint is set, but a chroot is required. " + "Is there a module before oemid that sets up the partitions?", + Calamares::JobResult::InvalidConfiguration ); + } + return writeId( Calamares::Settings::instance()->doChroot() ? rootMount + targetDir : targetDir, + targetFile, + m_batchIdentifier ); +} diff --git a/calamares/src/modules/oemid/IDJob.h b/calamares/src/modules/oemid/IDJob.h new file mode 100644 index 0000000..17d97b4 --- /dev/null +++ b/calamares/src/modules/oemid/IDJob.h @@ -0,0 +1,33 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef IDJOB_H +#define IDJOB_H + +#include "Job.h" + +#include + +class IDJob : public Calamares::Job +{ + Q_OBJECT +public: + explicit IDJob( const QString& id, QObject* parent = nullptr ); + + virtual QString prettyName() const override; + virtual Calamares::JobResult exec() override; + +private: + Calamares::JobResult writeId( const QString&, const QString&, const QString& ); + + QString m_batchIdentifier; +}; + + +#endif diff --git a/calamares/src/modules/oemid/OEMPage.ui b/calamares/src/modules/oemid/OEMPage.ui new file mode 100644 index 0000000..2194ac2 --- /dev/null +++ b/calamares/src/modules/oemid/OEMPage.ui @@ -0,0 +1,100 @@ + + + +SPDX-FileCopyrightText: 2019 Adriaan de Groot <groot@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + OEMPage + + + + 0 + 0 + 592 + 300 + + + + + 1 + 0 + + + + OEMPage + + + + + + + + Ba&tch: + + + batchIdentifier + + + + + + + <html><head/><body><p>Enter a batch-identifier here. This will be stored in the target system.</p></body></html> + + + batch-identifier + + + + + + + <html><head/><body><h1>OEM Configuration</h1><p>Calamares will use OEM settings while configuring the target system.</p></body></html> + + + Qt::RichText + + + Qt::AlignCenter + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 40 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/calamares/src/modules/oemid/OEMViewStep.cpp b/calamares/src/modules/oemid/OEMViewStep.cpp new file mode 100644 index 0000000..bf37001 --- /dev/null +++ b/calamares/src/modules/oemid/OEMViewStep.cpp @@ -0,0 +1,150 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "OEMViewStep.h" + +#include "ui_OEMPage.h" + +#include "IDJob.h" + +#include "utils/Retranslator.h" +#include "utils/StringExpander.h" +#include "utils/Variant.h" + +#include +#include +#include + +class OEMPage : public QWidget +{ +public: + OEMPage() + : QWidget( nullptr ) + , m_ui( new Ui_OEMPage() ) + { + m_ui->setupUi( this ); + + CALAMARES_RETRANSLATE( m_ui->retranslateUi( this ); ); + } + + ~OEMPage() override; + + Ui_OEMPage* m_ui; +}; + +OEMPage::~OEMPage() {} + +OEMViewStep::OEMViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_widget( nullptr ) + , m_visited( false ) +{ +} + +OEMViewStep::~OEMViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + +bool +OEMViewStep::isBackEnabled() const +{ + return true; +} + +bool +OEMViewStep::isNextEnabled() const +{ + return true; +} + +bool +OEMViewStep::isAtBeginning() const +{ + return true; +} + +bool +OEMViewStep::isAtEnd() const +{ + return true; +} + +static QString +substitute( QString s ) +{ + Calamares::String::DictionaryExpander d; + d.insert( QStringLiteral( "DATE" ), QDate::currentDate().toString( Qt::ISODate ) ); + + return d.expand( s ); +} + +void +OEMViewStep::onActivate() +{ + if ( !m_widget ) + { + (void)widget(); + } + if ( !m_visited && m_widget ) + { + m_widget->m_ui->batchIdentifier->setText( m_user_batchIdentifier ); + } + m_visited = true; + + ViewStep::onActivate(); +} + +void +OEMViewStep::onLeave() +{ + m_user_batchIdentifier = m_widget->m_ui->batchIdentifier->text(); + + ViewStep::onLeave(); +} + +QString +OEMViewStep::prettyName() const +{ + return tr( "OEM Configuration" ); +} + +QString +OEMViewStep::prettyStatus() const +{ + return tr( "Set the OEM Batch Identifier to %1." ).arg( m_user_batchIdentifier ); +} + +QWidget* +OEMViewStep::widget() +{ + if ( !m_widget ) + { + m_widget = new OEMPage; + } + return m_widget; +} + +Calamares::JobList +OEMViewStep::jobs() const +{ + return Calamares::JobList() << Calamares::job_ptr( new IDJob( m_user_batchIdentifier ) ); +} + +void +OEMViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_conf_batchIdentifier = Calamares::getString( configurationMap, "batch-identifier" ); + m_user_batchIdentifier = substitute( m_conf_batchIdentifier ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( OEMViewStepFactory, registerPlugin< OEMViewStep >(); ) diff --git a/calamares/src/modules/oemid/OEMViewStep.h b/calamares/src/modules/oemid/OEMViewStep.h new file mode 100644 index 0000000..a0b07c6 --- /dev/null +++ b/calamares/src/modules/oemid/OEMViewStep.h @@ -0,0 +1,57 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef OEMVIEWSTEP_H +#define OEMVIEWSTEP_H + +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include "DllMacro.h" + +#include + +class OEMPage; + +class PLUGINDLLEXPORT OEMViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit OEMViewStep( QObject* parent = nullptr ); + ~OEMViewStep() override; + + QString prettyName() const override; + QString prettyStatus() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onActivate() override; + void onLeave() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_conf_batchIdentifier; + QString m_user_batchIdentifier; + OEMPage* m_widget; + bool m_visited; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( OEMViewStepFactory ) + +#endif diff --git a/calamares/src/modules/oemid/oemid.conf b/calamares/src/modules/oemid/oemid.conf new file mode 100644 index 0000000..4fb14d9 --- /dev/null +++ b/calamares/src/modules/oemid/oemid.conf @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# This is an OEM setup (phase-0) configuration file. +--- +# The batch-identifier is written to /var/log/installer/oem-id. +# This value is put into the text box as the **suggested** +# OEM ID. If ${DATE} is included in the identifier, then +# that is replaced by the current date in yyyy-MM-dd (ISO) format. +# +# It is ok for the identifier to be empty. +# +# The identifier is written to the file as UTF-8 (this will be no +# different from ASCII, for most inputs) and followed by a newline. +# If the identifier is empty, only a newline is written. +batch-identifier: neon-${DATE} diff --git a/calamares/src/modules/openrcdmcryptcfg/main.py b/calamares/src/modules/openrcdmcryptcfg/main.py new file mode 100644 index 0000000..06f21da --- /dev/null +++ b/calamares/src/modules/openrcdmcryptcfg/main.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2017 Ghiunhan Mamut +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import os.path + +import libcalamares + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + + +def pretty_name(): + return _("Configuring OpenRC dmcrypt service.") + + +def write_dmcrypt_conf(partitions, root_mount_point, dmcrypt_conf_path): + crypto_target = "" + crypto_source = "" + unencrypted_separate_boot = any(p["mountPoint"] == "/boot" and "luksMapperName" not in p for p in partitions) + + for partition in partitions: + has_luks = "luksMapperName" in partition + skip_partitions = partition["mountPoint"] == "/" or partition["fs"] == "linuxswap" + + if not has_luks and not skip_partitions: + libcalamares.utils.debug( + "Skip writing OpenRC LUKS configuration for partition {!s}".format(partition["mountPoint"])) + if has_luks and not skip_partitions: + crypto_target = partition["luksMapperName"] + crypto_source = "/dev/disk/by-uuid/{!s}".format(partition["uuid"]) + libcalamares.utils.debug( + "Writing OpenRC LUKS configuration for partition {!s}".format(partition["mountPoint"])) + + with open(os.path.join(root_mount_point, dmcrypt_conf_path), 'a+') as dmcrypt_file: + dmcrypt_file.write("\ntarget=" + crypto_target) + dmcrypt_file.write("\nsource=" + crypto_source) + # Don't use keyfile if boot is unencrypted, keys must not be stored on unencrypted partitions + if not unencrypted_separate_boot: + dmcrypt_file.write("\nkey=/crypto_keyfile.bin") + dmcrypt_file.write("\n") + + if has_luks and skip_partitions: + pass # root and swap partitions should be handled by initramfs generators + + return None + +def run(): + """ + This module configures OpenRC dmcrypt service for LUKS encrypted partitions. + :return: + """ + + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + dmcrypt_conf_path = libcalamares.job.configuration["configFilePath"] + partitions = libcalamares.globalstorage.value("partitions") + + if not partitions: + libcalamares.utils.warning("partitions is empty, {!s}".format(partitions)) + return (_("Configuration Error"), + _("No partitions are defined for
{!s}
to use." ).format("openrcdmcryptcfg")) + if not root_mount_point: + libcalamares.utils.warning("rootMountPoint is empty, {!s}".format(root_mount_point)) + return (_("Configuration Error"), + _("No root mount point is given for
{!s}
to use." ).format("openrcdmcryptcfg")) + + dmcrypt_conf_path = dmcrypt_conf_path.lstrip('/') + + return write_dmcrypt_conf(partitions, root_mount_point, dmcrypt_conf_path) diff --git a/calamares/src/modules/openrcdmcryptcfg/module.desc b/calamares/src/modules/openrcdmcryptcfg/module.desc new file mode 100644 index 0000000..e633395 --- /dev/null +++ b/calamares/src/modules/openrcdmcryptcfg/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "openrcdmcryptcfg" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/openrcdmcryptcfg/openrcdmcryptcfg.conf b/calamares/src/modules/openrcdmcryptcfg/openrcdmcryptcfg.conf new file mode 100644 index 0000000..911a4ef --- /dev/null +++ b/calamares/src/modules/openrcdmcryptcfg/openrcdmcryptcfg.conf @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +configFilePath: /etc/conf.d/dmcrypt diff --git a/calamares/src/modules/packagechooser/CMakeLists.txt b/calamares/src/modules/packagechooser/CMakeLists.txt new file mode 100644 index 0000000..de51026 --- /dev/null +++ b/calamares/src/modules/packagechooser/CMakeLists.txt @@ -0,0 +1,52 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +find_package(${qtname} COMPONENTS Core Gui Widgets REQUIRED) + +### OPTIONAL AppData XML support in PackageModel +# +# +option(BUILD_APPDATA "Support appdata: items in PackageChooser (requires QtXml)" OFF) +if(BUILD_APPDATA) + find_package(${qtname} REQUIRED COMPONENTS Xml) +endif() + +### OPTIONAL AppStream support in PackageModel +# +# +include(AppStreamHelper) + +calamares_add_plugin(packagechooser + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + PackageChooserPage.cpp + PackageChooserViewStep.cpp + PackageModel.cpp + RESOURCES + packagechooser.qrc + UI + page_package.ui + SHARED_LIB +) + +if(AppStreamQt_FOUND) + target_link_libraries(${packagechooser_TARGET} PRIVATE calamares::appstreamqt) + target_sources(${packagechooser_TARGET} PRIVATE ItemAppStream.cpp) +endif() + +if(BUILD_APPDATA AND TARGET ${qtname}::Xml) + target_compile_definitions(${packagechooser_TARGET} PRIVATE HAVE_APPDATA) + target_link_libraries(${packagechooser_TARGET} PRIVATE ${qtname}::Xml) + target_sources(${packagechooser_TARGET} PRIVATE ItemAppData.cpp) +endif() + +calamares_add_test( + packagechoosertest + GUI + SOURCES Tests.cpp + LIBRARIES ${packagechooser_TARGET} +) diff --git a/calamares/src/modules/packagechooser/Config.cpp b/calamares/src/modules/packagechooser/Config.cpp new file mode 100644 index 0000000..642311b --- /dev/null +++ b/calamares/src/modules/packagechooser/Config.cpp @@ -0,0 +1,360 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#ifdef HAVE_APPDATA +#include "ItemAppData.h" +#endif + +#ifdef HAVE_APPSTREAM_VERSION +#include "ItemAppStream.h" +#include +#endif + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" +#include "packages/Globals.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +/** @brief This removes any values from @p groups that match @p source + * + * This is used to remove duplicates from the netinstallAdd structure + * It iterates over @p groups and for each map in the list, if the + * "source" element matches @p source, it is removed from the returned + * list. + */ +static QVariantList +pruneNetinstallAdd( const QString& source, const QVariant& groups ) +{ + QVariantList newGroupList; + const QVariantList groupList = groups.toList(); + for ( const QVariant& group : groupList ) + { + QVariantMap groupMap = group.toMap(); + if ( groupMap.value( "source", "" ).toString() != source ) + { + newGroupList.append( groupMap ); + } + } + return newGroupList; +} + +const NamedEnumTable< PackageChooserMode >& +packageChooserModeNames() +{ + static const NamedEnumTable< PackageChooserMode > names { + { "optional", PackageChooserMode::Optional }, + { "required", PackageChooserMode::Required }, + { "optionalmultiple", PackageChooserMode::OptionalMultiple }, + { "requiredmultiple", PackageChooserMode::RequiredMultiple }, + // and a bunch of aliases + { "zero-or-one", PackageChooserMode::Optional }, + { "radio", PackageChooserMode::Required }, + { "one", PackageChooserMode::Required }, + { "set", PackageChooserMode::OptionalMultiple }, + { "zero-or-more", PackageChooserMode::OptionalMultiple }, + { "multiple", PackageChooserMode::RequiredMultiple }, + { "one-or-more", PackageChooserMode::RequiredMultiple } + }; + return names; +} + +const NamedEnumTable< PackageChooserMethod >& +PackageChooserMethodNames() +{ + static const NamedEnumTable< PackageChooserMethod > names { + { "legacy", PackageChooserMethod::Legacy }, + { "custom", PackageChooserMethod::Legacy }, + { "contextualprocess", PackageChooserMethod::Legacy }, + { "packages", PackageChooserMethod::Packages }, + { "netinstall-add", PackageChooserMethod::NetAdd }, + { "netinstall-select", PackageChooserMethod::NetSelect }, + }; + return names; +} + +Config::Config( QObject* parent ) + : Calamares::ModuleSystem::Config( parent ) + , m_model( new PackageListModel( this ) ) + , m_mode( PackageChooserMode::Required ) +{ +} + +Config::~Config() {} + +const PackageItem& +Config::introductionPackage() const +{ + for ( int i = 0; i < m_model->packageCount(); ++i ) + { + const auto& package = m_model->packageData( i ); + if ( package.isNonePackage() ) + { + return package; + } + } + + static PackageItem* defaultIntroduction = nullptr; + if ( !defaultIntroduction ) + { + const auto name = QT_TR_NOOP( "Package Selection" ); + const auto description + = QT_TR_NOOP( "Please pick a product from the list. The selected product will be installed." ); + defaultIntroduction = new PackageItem( QString(), name, description ); + defaultIntroduction->screenshot = QPixmap( QStringLiteral( ":/images/no-selection.png" ) ); + defaultIntroduction->name = Calamares::Locale::TranslatedString( name, metaObject()->className() ); + defaultIntroduction->description + = Calamares::Locale::TranslatedString( description, metaObject()->className() ); + } + return *defaultIntroduction; +} + +static inline QString +make_gs_key( const Calamares::ModuleSystem::InstanceKey& key ) +{ + return QStringLiteral( "packagechooser_" ) + key.id(); +} + +void +Config::updateGlobalStorage( const QStringList& selected ) const +{ + if ( m_packageChoice.has_value() ) + { + cWarning() << "Inconsistent package choices -- both model and single-selection QML"; + } + if ( m_method == PackageChooserMethod::Legacy ) + { + QString value = selected.join( ',' ); + Calamares::JobQueue::instance()->globalStorage()->insert( make_gs_key( m_defaultId ), value ); + cDebug() << m_defaultId << "selected" << value; + } + else if ( m_method == PackageChooserMethod::Packages ) + { + QStringList packageNames = m_model->getInstallPackagesForNames( selected ); + cDebug() << m_defaultId << "packages to install" << packageNames; + Calamares::Packages::setGSPackageAdditions( + Calamares::JobQueue::instance()->globalStorage(), m_defaultId, packageNames ); + } + else if ( m_method == PackageChooserMethod::NetAdd ) + { + QVariantList netinstallDataList = m_model->getNetinstallDataForNames( selected ); + if ( netinstallDataList.isEmpty() ) + { + cWarning() << "No netinstall information found for " << selected; + } + else + { + // If an earlier packagechooser instance added this data to global storage, combine them + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( gs->contains( "netinstallAdd" ) ) + { + netinstallDataList + += pruneNetinstallAdd( QStringLiteral( "packageChooser" ), gs->value( "netinstallAdd" ) ); + } + gs->insert( "netinstallAdd", netinstallDataList ); + } + } + else if ( m_method == PackageChooserMethod::NetSelect ) + { + cDebug() << m_defaultId << "groups to select in netinstall" << selected; + QStringList newSelected = selected; + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + + // If an earlier packagechooser instance added this data to global storage, combine them + if ( gs->contains( "netinstallSelect" ) ) + { + auto selectedOrig = gs->value( "netinstallSelect" ); + if ( selectedOrig.canConvert< QStringList >() ) + { + newSelected += selectedOrig.toStringList(); + } + else + { + cWarning() << "Invalid NetinstallSelect data in global storage. Earlier selections purged"; + } + gs->remove( "netinstallSelect" ); + } + gs->insert( "netinstallSelect", newSelected ); + } + else + { + cWarning() << "Unknown packagechooser method" << smash( m_method ); + } +} + +void +Config::updateGlobalStorage() const +{ + if ( m_model->packageCount() > 0 ) + { + cWarning() << "Inconsistent package choices -- both model and single-selection QML"; + } + if ( m_method == PackageChooserMethod::Legacy ) + { + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( m_packageChoice.has_value() ) + { + gs->insert( make_gs_key( m_defaultId ), m_packageChoice.value() ); + } + else + { + gs->remove( make_gs_key( m_defaultId ) ); + } + } + else if ( m_method == PackageChooserMethod::Packages ) + { + cWarning() << "Unsupported single-selection packagechooser method 'Packages'"; + } + else + { + cWarning() << "Unknown packagechooser method" << smash( m_method ); + } +} + +void +Config::setPackageChoice( const QString& packageChoice ) +{ + if ( packageChoice.isEmpty() ) + { + m_packageChoice.reset(); + } + else + { + m_packageChoice = packageChoice; + } + emit packageChoiceChanged( m_packageChoice.value_or( QString() ) ); +} + +QString +Config::prettyName() const +{ + return m_stepName ? m_stepName->get() : tr( "Packages" ); +} + +QString +Config::prettyStatus() const +{ + return tr( "Install option: %1" ).arg( m_packageChoice.value_or( tr( "None" ) ) ); +} + +static void +fillModel( PackageListModel* model, const QVariantList& items ) +{ + if ( items.isEmpty() ) + { + cWarning() << "No *items* for PackageChooser module."; + return; + } + +#ifdef HAVE_APPSTREAM_VERSION + std::unique_ptr< AppStream::Pool > pool; + bool poolOk = false; +#endif + + cDebug() << "Loading PackageChooser model items from config"; + int item_index = 0; + for ( const auto& item_it : items ) + { + ++item_index; + QVariantMap item_map = item_it.toMap(); + if ( item_map.isEmpty() ) + { + cWarning() << "PackageChooser entry" << item_index << "is not valid."; + continue; + } + + if ( item_map.contains( "appdata" ) ) + { +#ifdef HAVE_XML + model->addPackage( fromAppData( item_map ) ); +#else + cWarning() << "Loading AppData XML is not supported."; +#endif + } + else if ( item_map.contains( "appstream" ) ) + { +#ifdef HAVE_APPSTREAM_VERSION + if ( !pool ) + { + pool = std::make_unique< AppStream::Pool >(); + pool->setLocale( QStringLiteral( "ALL" ) ); + poolOk = pool->load(); + } + if ( pool && poolOk ) + { + model->addPackage( fromAppStream( *pool, item_map ) ); + } +#else + cWarning() << "Loading AppStream data is not supported."; +#endif + } + else + { + model->addPackage( PackageItem( item_map ) ); + } + } + cDebug() << Logger::SubEntry << "Loaded PackageChooser with" << model->packageCount() << "entries."; +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_mode = packageChooserModeNames().find( Calamares::getString( configurationMap, "mode" ), + PackageChooserMode::Required ); + m_method = PackageChooserMethodNames().find( Calamares::getString( configurationMap, "method" ), + PackageChooserMethod::Legacy ); + + if ( m_method == PackageChooserMethod::Legacy ) + { + cDebug() << "Using module ID" << m_defaultId; + } + + if ( configurationMap.contains( "items" ) ) + { + fillModel( m_model, configurationMap.value( "items" ).toList() ); + + QString default_item_id = Calamares::getString( configurationMap, "default" ); + if ( !default_item_id.isEmpty() ) + { + for ( int item_n = 0; item_n < m_model->packageCount(); ++item_n ) + { + QModelIndex item_idx = m_model->index( item_n, 0 ); + QVariant item_id = m_model->data( item_idx, PackageListModel::IdRole ); + + if ( item_id.toString() == default_item_id ) + { + m_defaultModelIndex = item_idx; + break; + } + } + } + } + else + { + setPackageChoice( Calamares::getString( configurationMap, "packageChoice" ) ); + if ( m_method != PackageChooserMethod::Legacy ) + { + cWarning() << "Single-selection QML module must use 'Legacy' method."; + } + } + + bool labels_ok = false; + auto labels = Calamares::getSubMap( configurationMap, "labels", labels_ok ); + if ( labels_ok ) + { + if ( labels.contains( "step" ) ) + { + m_stepName = new Calamares::Locale::TranslatedString( labels, "step" ); + } + } +} diff --git a/calamares/src/modules/packagechooser/Config.h b/calamares/src/modules/packagechooser/Config.h new file mode 100644 index 0000000..f7a3165 --- /dev/null +++ b/calamares/src/modules/packagechooser/Config.h @@ -0,0 +1,128 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGECHOOSER_CONFIG_H +#define PACKAGECHOOSER_CONFIG_H + +#include "PackageModel.h" + +#include "modulesystem/Config.h" +#include "modulesystem/InstanceKey.h" + +#include +#include + +enum class PackageChooserMode +{ + Optional, // zero or one + Required, // exactly one + OptionalMultiple, // zero or more + RequiredMultiple // one or more +}; + +const NamedEnumTable< PackageChooserMode >& packageChooserModeNames(); + +enum class PackageChooserMethod +{ + Legacy, // use contextualprocess or other custom + Packages, // use the packages module + NetAdd, // adds packages to the netinstall module + NetSelect, // makes selections in the netinstall module +}; + +const NamedEnumTable< PackageChooserMethod >& PackageChooserMethodNames(); + +class Config : public Calamares::ModuleSystem::Config +{ + Q_OBJECT + + /** @brief This is the single-select package-choice + * + * For (QML) modules that support only a single selection and + * just want to do things in a straightforward pick-this-one + * way, the packageChoice property is a (the) way to go. + * + * Writing to this property means that any other form of package- + * choice or selection is ignored. + */ + Q_PROPERTY( QString packageChoice READ packageChoice WRITE setPackageChoice NOTIFY packageChoiceChanged ) + Q_PROPERTY( QString prettyStatus READ prettyStatus NOTIFY prettyStatusChanged FINAL ) + +public: + Config( QObject* parent = nullptr ); + ~Config() override; + + /** @brief Sets the default Id for this Config + * + * The default Id is the (owning) module identifier for the config, + * and it is used when the Id read from the config file is empty. + * The **usual** configuration when using method *packages* is + * to rely on the default Id. + */ + void setDefaultId( const Calamares::ModuleSystem::InstanceKey& defaultId ) { m_defaultId = defaultId; } + void setConfigurationMap( const QVariantMap& ) override; + + PackageChooserMode mode() const { return m_mode; } + PackageListModel* model() const { return m_model; } + QModelIndex defaultSelectionIndex() const { return m_defaultModelIndex; } + + /** @brief Returns an "introductory package" which describes packagechooser + * + * If the model contains a "none" package, returns that one on + * the assumption that it is one to describe the whole; otherwise + * returns a totally generic description. + */ + const PackageItem& introductionPackage() const; + + /** @brief Write selection to global storage + * + * Updates the GS keys for this packagechooser, marking all + * (and only) the packages in @p selected as selected. + */ + void updateGlobalStorage( const QStringList& selected ) const; + /** @brief Write selection to global storage + * + * Updates the GS keys for this packagechooser, marking **only** + * the package choice as selected. This assumes that the single- + * selection QML code is in use. + */ + void updateGlobalStorage() const; + + QString packageChoice() const { return m_packageChoice.value_or( QString() ); } + void setPackageChoice( const QString& packageChoice ); + + QString prettyName() const; + QString prettyStatus() const; + +signals: + void packageChoiceChanged( QString packageChoice ); + void prettyStatusChanged(); + +private: + PackageListModel* m_model = nullptr; + QModelIndex m_defaultModelIndex; + + /// Selection mode for this module + PackageChooserMode m_mode = PackageChooserMode::Optional; + /// How this module stores to GS + PackageChooserMethod m_method = PackageChooserMethod::Legacy; + /// Value to use for id if none is set in the config file + Calamares::ModuleSystem::InstanceKey m_defaultId; + /** @brief QML selection (for single-selection approaches) + * + * If there is no value, then there has been no selection. + * Reading the property will return an empty QString. + */ + std::optional< QString > m_packageChoice; + Calamares::Locale::TranslatedString* m_stepName; // As it appears in the sidebar +}; + + +#endif diff --git a/calamares/src/modules/packagechooser/ItemAppData.cpp b/calamares/src/modules/packagechooser/ItemAppData.cpp new file mode 100644 index 0000000..0986230 --- /dev/null +++ b/calamares/src/modules/packagechooser/ItemAppData.cpp @@ -0,0 +1,225 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/** @brief Loading items from AppData XML files. + * + * Only used if QtXML is found, implements PackageItem::fromAppData(). + */ +#include "PackageModel.h" + +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include +#include +#include + +/** @brief try to load the given @p fileName XML document + * + * Returns a QDomDocument, which will be valid iff the file can + * be read and contains valid XML data. + */ +static inline QDomDocument +loadAppData( const QString& fileName ) +{ + QFile file( fileName ); + if ( !file.open( QIODevice::ReadOnly ) ) + { + return QDomDocument(); + } + QDomDocument doc( "AppData" ); + if ( !doc.setContent( &file ) ) + { + file.close(); + return QDomDocument(); + } + file.close(); + return doc; +} + +/** @brief gets the text of child element @p tagName + */ +static inline QString +getChildText( const QDomNode& n, const QString& tagName ) +{ + QDomElement e = n.firstChildElement( tagName ); + return e.isNull() ? QString() : e.text(); +} + +/** @brief Gets a suitable screenshot path + * + * The element contains zero or more + * elements, which can have a *type* associated with them. + * Scan the screenshot elements, return the path + * for the one labeled with type=default or, if there is no + * default, the first element. + */ +static inline QString +getScreenshotPath( const QDomNode& n ) +{ + QDomElement shotsNode = n.firstChildElement( "screenshots" ); + if ( shotsNode.isNull() ) + { + return QString(); + } + + const QDomNodeList shotList = shotsNode.childNodes(); + int firstScreenshot = -1; // Use which screenshot node? + for ( int i = 0; i < shotList.count(); ++i ) + { + if ( !shotList.at( i ).isElement() ) + { + continue; + } + QDomElement e = shotList.at( i ).toElement(); + if ( e.tagName() != "screenshot" ) + { + continue; + } + // If none has the "type=default" attribute, use the first one + if ( firstScreenshot < 0 ) + { + firstScreenshot = i; + } + // But type=default takes precedence. + if ( e.hasAttribute( "type" ) && e.attribute( "type" ) == "default" ) + { + firstScreenshot = i; + break; + } + } + + if ( firstScreenshot >= 0 ) + { + return shotList.at( firstScreenshot ).firstChildElement( "image" ).text(); + } + + return QString(); +} + +/** @brief Returns language of the given element @p e + * + * Transforms the attribute value for xml:lang to something + * suitable for TranslatedString (e.g. [lang]). + */ +static inline QString +getLanguage( const QDomElement& e ) +{ + QString language = e.attribute( "xml:lang" ); + if ( !language.isEmpty() ) + { + language.replace( '-', '_' ); + language.prepend( '[' ); + language.append( ']' ); + } + return language; +} + +/** @brief Scan the list of @p children for @p tagname elements and add them to the map + * + * Uses @p mapname instead of @p tagname for the entries in map @p m + * to allow renaming from XML to map keys (in particular for + * TranslatedString). Also transforms xml:lang attributes to suitable + * key-decorations on @p mapname. + */ +static inline void +fillMap( QVariantMap& m, const QDomNodeList& children, const QString& tagname, const QString& mapname ) +{ + for ( int i = 0; i < children.count(); ++i ) + { + if ( !children.at( i ).isElement() ) + { + continue; + } + + QDomElement e = children.at( i ).toElement(); + if ( e.tagName() != tagname ) + { + continue; + } + + m[ mapname + getLanguage( e ) ] = e.text(); + } +} + +/** @brief gets the and elements +* +* Builds up a map of the elements (which may have a *lang* +* attribute to indicate translations and paragraphs of the +* element (also with lang). Uses the +* elements to supplement the description if no description +* is available for a given language. +* +* Returns a map with keys suitable for use by TranslatedString. +*/ +static inline QVariantMap +getNameAndSummary( const QDomNode& n ) +{ + QVariantMap m; + + const QDomNodeList children = n.childNodes(); + fillMap( m, children, "name", "name" ); + fillMap( m, children, "summary", "description" ); + + const QDomElement description = n.firstChildElement( "description" ); + if ( !description.isNull() ) + { + fillMap( m, description.childNodes(), "p", "description" ); + } + + return m; +} + +PackageItem +fromAppData( const QVariantMap& item_map ) +{ + QString fileName = Calamares::getString( item_map, "appdata" ); + if ( fileName.isEmpty() ) + { + cWarning() << "Can't load AppData without a suitable key."; + return PackageItem(); + } + cDebug() << "Loading AppData XML from" << fileName; + + QDomDocument doc = loadAppData( fileName ); + if ( doc.isNull() ) + { + return PackageItem(); + } + + QDomElement componentNode = doc.documentElement(); + if ( !componentNode.isNull() && componentNode.tagName() == "component" ) + { + // An "id" entry in the Calamares config overrides ID in the AppData + QString id = Calamares::getString( item_map, "id" ); + if ( id.isEmpty() ) + { + id = getChildText( componentNode, "id" ); + } + if ( id.isEmpty() ) + { + return PackageItem(); + } + + // A "screenshot" entry in the Calamares config overrides AppData + QString screenshotPath = Calamares::getString( item_map, "screenshot" ); + if ( screenshotPath.isEmpty() ) + { + screenshotPath = getScreenshotPath( componentNode ); + } + + QVariantMap map = getNameAndSummary( componentNode ); + map.insert( "id", id ); + map.insert( "screenshot", screenshotPath ); + + return PackageItem( map ); + } + + return PackageItem(); +} diff --git a/calamares/src/modules/packagechooser/ItemAppData.h b/calamares/src/modules/packagechooser/ItemAppData.h new file mode 100644 index 0000000..92d8122 --- /dev/null +++ b/calamares/src/modules/packagechooser/ItemAppData.h @@ -0,0 +1,28 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef ITEMAPPDATA_H +#define ITEMAPPDATA_H + +#include "PackageModel.h" + +/** @brief Loads an AppData XML file and returns a PackageItem + * + * The @p map must have a key *appdata*. That is used as the + * primary source of information, but keys *id* and *screenshotPath* + * may be used to override parts of the AppData -- so that the + * ID is under the control of Calamares, and the screenshot can be + * forced to a local path available on the installation medium. + * + * Requires XML support in libcalamares, if not present will + * return invalid PackageItems. + */ +PackageItem fromAppData( const QVariantMap& map ); + +#endif diff --git a/calamares/src/modules/packagechooser/ItemAppStream.cpp b/calamares/src/modules/packagechooser/ItemAppStream.cpp new file mode 100644 index 0000000..d45fd5b --- /dev/null +++ b/calamares/src/modules/packagechooser/ItemAppStream.cpp @@ -0,0 +1,159 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/** @brief Loading items from AppStream database. + * + * Only used if AppStreamQt is found, implements PackageItem::fromAppStream(). + */ +#include "ItemAppStream.h" + +#include "locale/TranslationsModel.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +/// @brief Return number of pixels in a size, for < ordering purposes +static inline quint64 +sizeOrder( const QSize& size ) +{ + return static_cast< quint64 >( size.width() ) * static_cast< quint64 >( size.height() ); +} + +/// @brief Sets a screenshot in @p map from @p screenshot, if a usable one is found +static void +setScreenshot( QVariantMap& map, const AppStream::Screenshot& screenshot ) +{ + if ( screenshot.images().count() < 1 ) + { + return; + } + + // Pick the smallest + QUrl url; + quint64 size = sizeOrder( screenshot.images().first().size() ); + for ( const auto& img : screenshot.images() ) + { + if ( sizeOrder( img.size() ) <= size ) + { + url = img.url(); + } + } + + if ( url.isValid() ) + { + map.insert( "screenshot", url.toString() ); + } +} + +/// @brief Interpret an AppStream Component +static PackageItem +fromComponent( AppStream::Pool& pool, AppStream::Component& component ) +{ +#if HAVE_APPSTREAM_VERSION == 0 + auto setActiveLocale = [ &component ]( const QString& locale ) { component.setActiveLocale( locale ); }; +#else + auto setActiveLocale = [ &pool ]( const QString& locale ) { pool.setLocale( locale ); }; +#endif + + QVariantMap map; + map.insert( "id", component.id() ); + map.insert( "package", component.packageNames().join( "," ) ); + + // Assume that the pool has loaded "ALL" locales, but it might be set + // to any of them; get the en_US locale as "untranslated" and then + // loop over Calamares locales (since there is no way to query for + // available locales in the Component) to see if there's anything else. + setActiveLocale( QStringLiteral( "en_US" ) ); + QString en_name = component.name(); + QString en_description = component.description(); + map.insert( "name", en_name ); + map.insert( "description", en_description ); + + for ( const QString& locale : Calamares::Locale::availableTranslations()->localeIds() ) + { + setActiveLocale( locale ); + QString name = component.name(); + if ( name != en_name ) + { + map.insert( QStringLiteral( "name[%1]" ).arg( locale ), name ); + } + QString description = component.description(); + if ( description != en_description ) + { + map.insert( QStringLiteral( "description[%1]" ).arg( locale ), description ); + } + } + +#if HAVE_APPSTREAM_VERSION == 0 + auto screenshots = component.screenshots(); +#else + auto screenshots = component.screenshotsAll(); +#endif + if ( screenshots.count() > 0 ) + { + bool done = false; + for ( const auto& s : screenshots ) + { + if ( s.isDefault() ) + { + setScreenshot( map, s ); + done = true; + break; + } + } + if ( !done ) + { + setScreenshot( map, screenshots.first() ); + } + } + + return PackageItem( map ); +} + +PackageItem +fromAppStream( AppStream::Pool& pool, const QVariantMap& item_map ) +{ + QString appstreamId = Calamares::getString( item_map, "appstream" ); + if ( appstreamId.isEmpty() ) + { + cWarning() << "Can't load AppStream without a suitable appstreamId."; + return PackageItem(); + } + cDebug() << "Loading AppStream data for" << appstreamId; + +#if HAVE_APPSTREAM_VERSION == 0 + auto itemList = pool.componentsById( appstreamId ); +#else + auto itemList = pool.componentsById( appstreamId ).toList(); +#endif + if ( itemList.count() < 1 ) + { + cWarning() << "No AppStream data for" << appstreamId; + return PackageItem(); + } + if ( itemList.count() > 1 ) + { + cDebug() << "Multiple AppStream data for" << appstreamId << "using first."; + } + + auto r = fromComponent( pool, itemList.first() ); + if ( r.isValid() ) + { + QString id = Calamares::getString( item_map, "id" ); + QString screenshotPath = Calamares::getString( item_map, "screenshot" ); + if ( !id.isEmpty() ) + { + r.id = id; + } + if ( !screenshotPath.isEmpty() ) + { + r.screenshot = screenshotPath; + } + } + return r; +} diff --git a/calamares/src/modules/packagechooser/ItemAppStream.h b/calamares/src/modules/packagechooser/ItemAppStream.h new file mode 100644 index 0000000..8e946af --- /dev/null +++ b/calamares/src/modules/packagechooser/ItemAppStream.h @@ -0,0 +1,54 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef ITEMAPPSTREAM_H +#define ITEMAPPSTREAM_H + +#include "PackageModel.h" + +/* + * This weird include mechanism is because an #include line is allowed + * to consist of preprocessor-tokens, which are expanded, and then + * the #include is *re*processed. But if it starts with < or ", then + * preprocessor tokens are not expanded. So we build up a #include <> + * style line with a suitable path -- if we are given a value for + * HAVE_APPSTREAM_HEADERS, that is the directory that the AppStreamQt + * headers live in. + */ +#define CALAMARES_LT < +#define CALAMARES_GT > + +#ifndef HAVE_APPSTREAM_HEADERS +#define HAVE_APPSTREAM_HEADERS AppStreamQt +#endif + +// clang-format off +#include CALAMARES_LT HAVE_APPSTREAM_HEADERS/pool.h CALAMARES_GT +#include CALAMARES_LT HAVE_APPSTREAM_HEADERS/image.h CALAMARES_GT +#include CALAMARES_LT HAVE_APPSTREAM_HEADERS/screenshot.h CALAMARES_GT +// clang-format on + +#undef CALAMARES_LT +#undef CALAMARES_GT + +/** @brief Loads an item from AppStream data. + * + * The @p map must have a key *appstream*. That is used as the + * primary source of information from the AppStream cache, but + * keys *id* and *screenshotPath* may be used to override parts + * of the AppStream data -- so that the ID is under the control + * of Calamares, and the screenshot can be forced to a local path + * available on the installation medium. + * + * Requires AppStreamQt, if not present will return invalid + * PackageItems. + */ +PackageItem fromAppStream( AppStream::Pool& pool, const QVariantMap& map ); + +#endif diff --git a/calamares/src/modules/packagechooser/PackageChooserPage.cpp b/calamares/src/modules/packagechooser/PackageChooserPage.cpp new file mode 100644 index 0000000..44a570d --- /dev/null +++ b/calamares/src/modules/packagechooser/PackageChooserPage.cpp @@ -0,0 +1,145 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PackageChooserPage.h" + +#include "ui_page_package.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" + +#include + +PackageChooserPage::PackageChooserPage( PackageChooserMode mode, QWidget* parent ) + : QWidget( parent ) + , ui( new Ui::PackageChooserPage ) + , m_introduction( QString(), + QString(), + tr( "Package Selection" ), + tr( "Please pick a product from the list. The selected product will be installed." ) ) +{ + m_introduction.screenshot = QPixmap( QStringLiteral( ":/images/no-selection.png" ) ); + + ui->setupUi( this ); + CALAMARES_RETRANSLATE( updateLabels(); ); + + switch ( mode ) + { + case PackageChooserMode::Optional: + [[fallthrough]]; + case PackageChooserMode::Required: + ui->products->setSelectionMode( QAbstractItemView::SingleSelection ); + break; + case PackageChooserMode::OptionalMultiple: + [[fallthrough]]; + case PackageChooserMode::RequiredMultiple: + ui->products->setSelectionMode( QAbstractItemView::ExtendedSelection ); + } + + ui->products->setMinimumWidth( 10 * Calamares::defaultFontHeight() ); +} + +void +PackageChooserPage::currentChanged( const QModelIndex& index ) +{ + if ( !index.isValid() || !ui->products->selectionModel()->hasSelection() ) + { + ui->productName->setText( m_introduction.name.get() ); + ui->productScreenshot->setPixmap( m_introduction.screenshot ); + ui->productDescription->setText( m_introduction.description.get() ); + } + else + { + const auto* model = ui->products->model(); + + ui->productName->setText( model->data( index, PackageListModel::NameRole ).toString() ); + ui->productDescription->setText( model->data( index, PackageListModel::DescriptionRole ).toString() ); + + QPixmap currentScreenshot = model->data( index, PackageListModel::ScreenshotRole ).value< QPixmap >(); + if ( currentScreenshot.isNull() ) + { + ui->productScreenshot->setPixmap( m_introduction.screenshot ); + } + else + { + ui->productScreenshot->setPixmap( currentScreenshot ); + } + } +} + +void +PackageChooserPage::updateLabels() +{ + if ( ui && ui->products && ui->products->selectionModel() ) + { + currentChanged( ui->products->selectionModel()->currentIndex() ); + } + else + { + currentChanged( QModelIndex() ); + } + emit selectionChanged(); +} + +void +PackageChooserPage::setModel( QAbstractItemModel* model ) +{ + ui->products->setModel( model ); + currentChanged( QModelIndex() ); + connect( ui->products->selectionModel(), + &QItemSelectionModel::selectionChanged, + this, + &PackageChooserPage::updateLabels ); +} + +void +PackageChooserPage::setSelection( const QModelIndex& index ) +{ + if ( index.isValid() ) + { + ui->products->selectionModel()->select( index, QItemSelectionModel::Select ); + } + currentChanged( index ); +} + +bool +PackageChooserPage::hasSelection() const +{ + return ui && ui->products && ui->products->selectionModel() && ui->products->selectionModel()->hasSelection(); +} + +QStringList +PackageChooserPage::selectedPackageIds() const +{ + if ( !( ui && ui->products && ui->products->selectionModel() ) ) + { + return QStringList(); + } + + const auto* model = ui->products->model(); + QStringList ids; + for ( const auto& index : ui->products->selectionModel()->selectedIndexes() ) + { + QString pid = model->data( index, PackageListModel::IdRole ).toString(); + if ( !pid.isEmpty() ) + { + ids.append( pid ); + } + } + return ids; +} + +void +PackageChooserPage::setIntroduction( const PackageItem& item ) +{ + m_introduction.name = item.name; + m_introduction.description = item.description; + m_introduction.screenshot = item.screenshot; +} diff --git a/calamares/src/modules/packagechooser/PackageChooserPage.h b/calamares/src/modules/packagechooser/PackageChooserPage.h new file mode 100644 index 0000000..1889ef5 --- /dev/null +++ b/calamares/src/modules/packagechooser/PackageChooserPage.h @@ -0,0 +1,57 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGECHOOSERPAGE_H +#define PACKAGECHOOSERPAGE_H + +#include "Config.h" +#include "PackageModel.h" + +#include +#include + +namespace Ui +{ +class PackageChooserPage; +} // namespace Ui + +class PackageChooserPage : public QWidget +{ + Q_OBJECT +public: + explicit PackageChooserPage( PackageChooserMode mode, QWidget* parent = nullptr ); + + /// @brief Sets the data model for the listview + void setModel( QAbstractItemModel* model ); + + /// @brief Sets the introductory (no-package-selected) texts + void setIntroduction( const PackageItem& item ); + /// @brief Selects a listview item + void setSelection( const QModelIndex& index ); + /// @brief Is anything selected? + bool hasSelection() const; + /** @brief Get the list of selected ids + * + * This list may be empty (if none is selected). + */ + QStringList selectedPackageIds() const; + +public slots: + void currentChanged( const QModelIndex& index ); + void updateLabels(); + +signals: + void selectionChanged(); + +private: + Ui::PackageChooserPage* ui; + PackageItem m_introduction; +}; + +#endif // PACKAGECHOOSERPAGE_H diff --git a/calamares/src/modules/packagechooser/PackageChooserViewStep.cpp b/calamares/src/modules/packagechooser/PackageChooserViewStep.cpp new file mode 100644 index 0000000..a6be745 --- /dev/null +++ b/calamares/src/modules/packagechooser/PackageChooserViewStep.cpp @@ -0,0 +1,158 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PackageChooserViewStep.h" + +#include "Config.h" +#include "PackageChooserPage.h" +#include "PackageModel.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "locale/TranslatableConfiguration.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include +#include + +CALAMARES_PLUGIN_FACTORY_DEFINITION( PackageChooserViewStepFactory, registerPlugin< PackageChooserViewStep >(); ) + +PackageChooserViewStep::PackageChooserViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_config( new Config( this ) ) + , m_widget( nullptr ) +{ + emit nextStatusChanged( false ); +} + + +PackageChooserViewStep::~PackageChooserViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +PackageChooserViewStep::prettyName() const +{ + return m_config->prettyName(); +} + + +QWidget* +PackageChooserViewStep::widget() +{ + if ( !m_widget ) + { + m_widget = new PackageChooserPage( m_config->mode(), nullptr ); + connect( m_widget, + &PackageChooserPage::selectionChanged, + [ = ]() { emit nextStatusChanged( this->isNextEnabled() ); } ); + hookupModel(); + } + return m_widget; +} + + +bool +PackageChooserViewStep::isNextEnabled() const +{ + if ( !m_widget ) + { + // No way to have changed anything + return true; + } + + switch ( m_config->mode() ) + { + case PackageChooserMode::Optional: + case PackageChooserMode::OptionalMultiple: + // zero or one OR zero or more + return true; + case PackageChooserMode::Required: + case PackageChooserMode::RequiredMultiple: + // exactly one OR one or more + return m_widget->hasSelection(); + } + __builtin_unreachable(); +} + + +bool +PackageChooserViewStep::isBackEnabled() const +{ + return true; +} + + +bool +PackageChooserViewStep::isAtBeginning() const +{ + return true; +} + + +bool +PackageChooserViewStep::isAtEnd() const +{ + return true; +} + +void +PackageChooserViewStep::onActivate() +{ + if ( !m_widget->hasSelection() ) + { + m_widget->setSelection( m_config->defaultSelectionIndex() ); + } +} + +void +PackageChooserViewStep::onLeave() +{ + m_config->updateGlobalStorage( m_widget->selectedPackageIds() ); +} + +Calamares::JobList +PackageChooserViewStep::jobs() const +{ + Calamares::JobList l; + return l; +} + +void +PackageChooserViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setDefaultId( moduleInstanceKey() ); + m_config->setConfigurationMap( configurationMap ); + + if ( m_widget ) + { + hookupModel(); + } +} + + +void +PackageChooserViewStep::hookupModel() +{ + if ( !m_config->model() || !m_widget ) + { + cError() << "Can't hook up model until widget and model both exist."; + return; + } + + m_widget->setModel( m_config->model() ); + m_widget->setIntroduction( m_config->introductionPackage() ); +} diff --git a/calamares/src/modules/packagechooser/PackageChooserViewStep.h b/calamares/src/modules/packagechooser/PackageChooserViewStep.h new file mode 100644 index 0000000..76b35ae --- /dev/null +++ b/calamares/src/modules/packagechooser/PackageChooserViewStep.h @@ -0,0 +1,57 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGECHOOSERVIEWSTEP_H +#define PACKAGECHOOSERVIEWSTEP_H + +#include "DllMacro.h" +#include "locale/TranslatableConfiguration.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include + +class Config; +class PackageChooserPage; + +class PLUGINDLLEXPORT PackageChooserViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit PackageChooserViewStep( QObject* parent = nullptr ); + ~PackageChooserViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onActivate() override; + void onLeave() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + void hookupModel(); + + Config* m_config; + PackageChooserPage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( PackageChooserViewStepFactory ) + +#endif // PACKAGECHOOSERVIEWSTEP_H diff --git a/calamares/src/modules/packagechooser/PackageModel.cpp b/calamares/src/modules/packagechooser/PackageModel.cpp new file mode 100644 index 0000000..f2a0b43 --- /dev/null +++ b/calamares/src/modules/packagechooser/PackageModel.cpp @@ -0,0 +1,196 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PackageModel.h" + +#include "Branding.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include + +/** @brief A wrapper for Calamares::getSubMap that excludes the success param + */ +static QVariantMap +getSubMap( const QVariantMap& map, const QString& key ) +{ + bool success; + + return Calamares::getSubMap( map, key, success ); +} + +static QPixmap +loadScreenshot( const QString& path ) +{ + if ( QFileInfo::exists( path ) ) + { + return QPixmap( path ); + } + + const auto* branding = Calamares::Branding::instance(); + if ( !branding ) + { + return QPixmap(); + } + return QPixmap( branding->componentDirectory() + QStringLiteral( "/" ) + path ); +} + +PackageItem::PackageItem() {} + +PackageItem::PackageItem( const QString& a_id, const QString& a_name, const QString& a_description ) + : id( a_id ) + , name( a_name ) + , description( a_description ) +{ +} + +PackageItem::PackageItem( const QString& a_id, + const QString& a_name, + const QString& a_description, + const QString& screenshotPath ) + : id( a_id ) + , name( a_name ) + , description( a_description ) + , screenshot( screenshotPath ) +{ +} + +PackageItem::PackageItem( const QVariantMap& item_map ) + : id( Calamares::getString( item_map, "id" ) ) + , name( Calamares::Locale::TranslatedString( item_map, "name" ) ) + , description( Calamares::Locale::TranslatedString( item_map, "description" ) ) + , screenshot( loadScreenshot( Calamares::getString( item_map, "screenshot" ) ) ) + , packageNames( Calamares::getStringList( item_map, "packages" ) ) + , netinstallData( getSubMap( item_map, "netinstall" ) ) +{ + if ( name.isEmpty() && id.isEmpty() ) + { + name = QObject::tr( "No product" ); + } + else if ( name.isEmpty() ) + { + cWarning() << "PackageChooser item" << id << "has an empty name."; + } + if ( description.isEmpty() ) + { + description = QObject::tr( "No description provided." ); + } +} + +PackageListModel::PackageListModel( QObject* parent ) + : QAbstractListModel( parent ) +{ +} + +PackageListModel::PackageListModel( PackageList&& items, QObject* parent ) + : QAbstractListModel( parent ) + , m_packages( std::move( items ) ) +{ +} + +PackageListModel::~PackageListModel() {} + +void +PackageListModel::addPackage( PackageItem&& p ) +{ + // Only add valid packages + if ( p.isValid() ) + { + int c = m_packages.count(); + beginInsertRows( QModelIndex(), c, c ); + m_packages.append( p ); + endInsertRows(); + } +} + +QStringList +PackageListModel::getInstallPackagesForName( const QString& id ) const +{ + for ( const auto& p : std::as_const( m_packages ) ) + { + if ( p.id == id ) + { + return p.packageNames; + } + } + return QStringList(); +} + +QStringList +PackageListModel::getInstallPackagesForNames( const QStringList& ids ) const +{ + QStringList l; + for ( const auto& p : std::as_const( m_packages ) ) + { + if ( ids.contains( p.id ) ) + { + l.append( p.packageNames ); + } + } + return l; +} + +QVariantList +PackageListModel::getNetinstallDataForNames( const QStringList& ids ) const +{ + QVariantList l; + for ( auto& p : m_packages ) + { + if ( ids.contains( p.id ) ) + { + if ( !p.netinstallData.isEmpty() ) + { + QVariantMap newData = p.netinstallData; + newData[ "source" ] = QStringLiteral( "packageChooser" ); + l.append( newData ); + } + } + } + return l; +} + +int +PackageListModel::rowCount( const QModelIndex& index ) const +{ + // For lists, valid indexes have zero children; only the root index has them + return index.isValid() ? 0 : m_packages.count(); +} + +QVariant +PackageListModel::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() ) + { + return QVariant(); + } + int row = index.row(); + if ( row >= m_packages.count() || row < 0 ) + { + return QVariant(); + } + + if ( role == Qt::DisplayRole /* Also PackageNameRole */ ) + { + return m_packages[ row ].name.get(); + } + else if ( role == DescriptionRole ) + { + return m_packages[ row ].description.get(); + } + else if ( role == ScreenshotRole ) + { + return m_packages[ row ].screenshot; + } + else if ( role == IdRole ) + { + return m_packages[ row ].id; + } + + return QVariant(); +} diff --git a/calamares/src/modules/packagechooser/PackageModel.h b/calamares/src/modules/packagechooser/PackageModel.h new file mode 100644 index 0000000..ed7ffcf --- /dev/null +++ b/calamares/src/modules/packagechooser/PackageModel.h @@ -0,0 +1,135 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGEMODEL_H +#define PACKAGEMODEL_H + +#include "locale/TranslatableConfiguration.h" +#include "utils/NamedEnum.h" + +#include +#include +#include +#include + + +struct PackageItem +{ + QString id; + Calamares::Locale::TranslatedString name; + Calamares::Locale::TranslatedString description; + QPixmap screenshot; + QStringList packageNames; + QVariantMap netinstallData; + + /// @brief Create blank PackageItem + PackageItem(); + /** @brief Creates a PackageItem from given strings + * + * This constructor sets all the text members, + * but leaves the screenshot blank. Set that separately. + */ + PackageItem( const QString& id, const QString& name, const QString& description ); + + /** @brief Creates a PackageItem from given strings. + * + * Set all the text members and load the screenshot from the given + * @p screenshotPath, which may be a QRC path (:/path/in/qrc) or + * a filesystem path, whatever QPixmap understands. + */ + PackageItem( const QString& id, const QString& name, const QString& description, const QString& screenshotPath ); + + /** @brief Creates a PackageItem from a QVariantMap + * + * This is intended for use when loading PackageItems from a + * configuration map. It will look up the various keys in the map + * and handle translation strings as well. + * + * The following keys are used: + * - *id*: the identifier for this item; if it is the empty string + * then this is the special "no-package". + * - *name* (and *name[lang]*): for the name and its translations + * - *description* (and *description[lang]*) + * - *screenshot*: a path to a screenshot for this package + * - *packages*: a list of package names + */ + PackageItem( const QVariantMap& map ); + + /** @brief Is this item valid? + * + * A valid item has an untranslated name available. + */ + bool isValid() const { return !name.isEmpty(); } + + /** @brief Is this a (the) No-Package package? + * + * There should be at most one No-Package item in a collection + * of PackageItems. That one will be used to describe a + * "no package" situation. + */ + bool isNonePackage() const { return id.isEmpty(); } +}; + +using PackageList = QVector< PackageItem >; + +class PackageListModel : public QAbstractListModel +{ +public: + PackageListModel( PackageList&& items, QObject* parent ); + PackageListModel( QObject* parent ); + ~PackageListModel() override; + + /** @brief Add a package @p to the model + * + * Only valid packages are added -- that is, they must have a name. + */ + void addPackage( PackageItem&& p ); + + int rowCount( const QModelIndex& index ) const override; + QVariant data( const QModelIndex& index, int role ) const override; + + /// @brief Direct (non-abstract) access to package data + const PackageItem& packageData( int r ) const { return m_packages[ r ]; } + /// @brief Direct (non-abstract) count of package data + int packageCount() const { return m_packages.count(); } + + /** @brief Does a name lookup (based on id) and returns the packages member + * + * If there is a package with the given @p id, returns its packages + * (e.g. the names of underlying packages to install for it); returns + * an empty list if the id is not found. + */ + QStringList getInstallPackagesForName( const QString& id ) const; + /** @brief Name-lookup all the @p ids and returns the packages members + * + * Concatenates installPackagesForName() for each id in @p ids. + */ + QStringList getInstallPackagesForNames( const QStringList& ids ) const; + + /** @brief Does a name lookup (based on id) and returns the netinstall data + * + * If there is a package with an id in @p ids, returns their netinstall data + * + * returns a list of netinstall data or an emply list if none is found + */ + QVariantList getNetinstallDataForNames( const QStringList& ids ) const; + + enum Roles : int + { + NameRole = Qt::DisplayRole, + DescriptionRole = Qt::UserRole, + ScreenshotRole, + IdRole + }; + +private: + PackageList m_packages; +}; + +#endif diff --git a/calamares/src/modules/packagechooser/Tests.cpp b/calamares/src/modules/packagechooser/Tests.cpp new file mode 100644 index 0000000..74564da --- /dev/null +++ b/calamares/src/modules/packagechooser/Tests.cpp @@ -0,0 +1,84 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Tests.h" + +#ifdef HAVE_APPDATA +#include "ItemAppData.h" +#endif +#ifdef HAVE_APPSTREAM_VERSION +#include "ItemAppStream.h" +#endif +#include "PackageModel.h" + +#include "utils/Logger.h" + +#include + +QTEST_GUILESS_MAIN( PackageChooserTests ) + +PackageChooserTests::PackageChooserTests() {} + +PackageChooserTests::~PackageChooserTests() {} + +void +PackageChooserTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +PackageChooserTests::testBogus() +{ + QVERIFY( true ); +} + +void +PackageChooserTests::testAppData() +{ + // Path from the build-dir and from the running-the-test varies, + // for in-source build, for build/, and for tests-in-build/, + // so look in multiple places. + QString appdataName( "io.calamares.calamares.appdata.xml" ); + for ( const auto& prefix : QStringList { "", "../", "../../../", "../../../../" } ) + { + if ( QFile::exists( prefix + appdataName ) ) + { + appdataName = prefix + appdataName; + break; + } + } + QVERIFY( QFile::exists( appdataName ) ); + + QVariantMap m; + m.insert( "appdata", appdataName ); + +#ifdef HAVE_XML + PackageItem p1 = fromAppData( m ); + QVERIFY( p1.isValid() ); + QCOMPARE( p1.id, QStringLiteral( "io.calamares.calamares.desktop" ) ); + QCOMPARE( p1.name.get(), QStringLiteral( "Calamares" ) ); + // The entry has precedence + QCOMPARE( p1.description.get(), QStringLiteral( "Calamares is an installer program for Linux distributions." ) ); + // .. but en_GB doesn't have an entry in description, so uses + QCOMPARE( p1.description.get( QLocale( "en_GB" ) ), QStringLiteral( "Calamares Linux Installer" ) ); + QCOMPARE( p1.description.get( QLocale( "nl" ) ), + QStringLiteral( "Calamares is een installatieprogramma voor Linux distributies." ) ); + QVERIFY( p1.screenshot.isNull() ); + + m.insert( "id", "calamares" ); + m.insert( "screenshot", ":/images/calamares.png" ); + PackageItem p2 = fromAppData( m ); + QVERIFY( p2.isValid() ); + QCOMPARE( p2.id, QStringLiteral( "calamares" ) ); + QCOMPARE( p2.description.get( QLocale( "nl" ) ), + QStringLiteral( "Calamares is een installatieprogramma voor Linux distributies." ) ); + QVERIFY( !p2.screenshot.isNull() ); +#endif +} diff --git a/calamares/src/modules/packagechooser/Tests.h b/calamares/src/modules/packagechooser/Tests.h new file mode 100644 index 0000000..34ac2f6 --- /dev/null +++ b/calamares/src/modules/packagechooser/Tests.h @@ -0,0 +1,28 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGECHOOSERTESTS_H +#define PACKAGECHOOSERTESTS_H + +#include + +class PackageChooserTests : public QObject +{ + Q_OBJECT +public: + PackageChooserTests(); + ~PackageChooserTests() override; + +private Q_SLOTS: + void initTestCase(); + void testBogus(); + void testAppData(); +}; + +#endif diff --git a/calamares/src/modules/packagechooser/images/calamares.png b/calamares/src/modules/packagechooser/images/calamares.png new file mode 100644 index 0000000000000000000000000000000000000000..452e4450c56c10cda33dcc9c5d03753ace458862 GIT binary patch literal 8313 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 +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/packagechooser/images/no-selection.png b/calamares/src/modules/packagechooser/images/no-selection.png new file mode 100644 index 0000000000000000000000000000000000000000..437c97051cb9d0d16049ebff1bdc6cffe1ddb8a1 GIT binary patch literal 1709 zcmd^9`BT$J6yI+Mm;eGwXF}Bmt1XNpBzR(_K&+I2q~Mi7DuE~pB4VhN5+g^zY7a)x zVW`mn83v2+MFcD01kJ$!MwF{k2#|vY6ha_`1QG~G(jdx@{RiBco%i-#`}yp=eZL(J zC9bvIU<&|P8ypmH6adJgLI7oB5s{b5V=V%m6cmvRfJ5{0h6=VjYy`k2Iym6KaeARz zCP<)+ekRgtZl#^wbnIYo@RlT1W9~M2Vav^+WA^G^|IM=3^dAyMGIj}$VoTl{caF{2 zc1Gh$ZFsTm3vw{JrK`ED-7)4nTbGwDm^V;A_07PrCZ=#V3^B9hqOy$mvhv`Z6vZ1|E1k zUcQk=i60rCJ05~$i2ceXM2GgJ3yF#9i{bBRcdm0cw= z(mEm5B~PRlaWa0&JgZl=JNh@IEXwKQhsZ^hx%t90F=l^y>=PJas*=^vHCPJLl`ErN?+r{u4f*Yc!51T1rLO3sU2ot2l9IO2(h{z9-2k z#-VG+?Cj)*rkfrf9{;?}!Gw}~FjwkwI-owjB3xAL%Scl7Ks-r5+s9&^bQT(985-j$ z7QfcpCr{ap1asHd0hAkXB7w~;K=6W?R0L>5f^0NEI)5~dzN`p5leo}5IN8BK=T*hS z8FPz64{kSZMG2Wp+D`L?nj0QLXu*gQK1RxeKyCaWawCxinCh3d85$ z7GNr2mCi8C6VJ>%o3M6XiI5C}!vdqWDc@Y@Ka--uCg&>c)h| zr@fVxl2kK(pRx3P-2p5OvCmkso_Qkt3C*N-`k93AqNZp5ZgQ8fP3_4ceve#V`o6oX z#?ODvVGDx6YVm0n36yl0C!dQ=Y!5f)=aTA}ey!^(Bxoc7tKM0z)YKWSLlJ7G1r)Y^ zJ!=OIddES^E~TyDuJJR+IF;{&2)mtyOzs-OAZfL)W`WIvPJgrw*$w<^Vd!NA(sw}w zFH78~K*>gf>^s1wjRvi%#<1n&Hl){?K){Gy;W>@TTJ?)8S-`$8b5dHUUvGHf7|R3L z1Hk6O(m+@o56M?Ztv<>|{=Y9=6OZt!fH&lc62lT8Y}0MS$i4ABMOIz4bw|j0?R}1n zbBQAK>-#V%2;Gs@@b?isM)JPckJiJ`BU wydCia1Lb90lj*6q{2^Qwfk%B9@1PHFwr;kvMwtJ0mo6KFzYPs|_zgAtKayIY1ONa4 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/packagechooser/images/no-selection.png.license b/calamares/src/modules/packagechooser/images/no-selection.png.license new file mode 100644 index 0000000..ef0e9d7 --- /dev/null +++ b/calamares/src/modules/packagechooser/images/no-selection.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2014 Uri Herrera and others +SPDX-License-Identifier: LGPL-3.0-or-later diff --git a/calamares/src/modules/packagechooser/packagechooser.conf b/calamares/src/modules/packagechooser/packagechooser.conf new file mode 100644 index 0000000..5b40aeb --- /dev/null +++ b/calamares/src/modules/packagechooser/packagechooser.conf @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the low-density software chooser +--- +# Software selection mode, to set whether the software packages +# can be chosen singly, or multiply. +# +# Possible modes are "optional", "required" (for zero-or-one or exactly-one) +# or "optionalmultiple", "requiredmultiple" (for zero-or-more +# or one-or-more). +mode: required + +# Software installation method: +# +# - "legacy" or "custom" or "contextualprocess" +# When set to "legacy", writes a GlobalStorage value for the choice that +# has been made. The key is *packagechooser_*. The module's +# instance name is used; see the *instances* section of `settings.conf`. +# If there is just one packagechooser module, and no special instance is set, +# resulting GS key is probably *packagechooser_packagechooser*. +# +# The GS value is a comma-separated list of the IDs of the selected +# packages, or an empty string if none is selected. +# +# With "legacy" installation, you should have a contextualprocess or similar +# module somewhere in the `exec` phase to process the GlobalStorage key +# and actually **do** something for the packages. +# +# - "packages" +# When set to "packages", writes GlobalStorage values suitable for +# consumption by the *packages* module (which should appear later +# in the `exec` section. These package settings will then be handed +# off to whatever package manager is configured there. +# +# - "netinstall-select" +# When this is set, the id(s) selected are passed to the netinstall module. +# Any id that matches a group name in that module is set to checked +# +# - "netinstall-add" +# With this method, the packagechooser module is used to add groups to the +# netinstall module. For this to have any effect. You must set netinstall, +# which is described below. +# +# There is no need to put this module in the `exec` section. There +# are no jobs that this module provides. You should put **other** +# modules, either *contextualprocess* or *packages* or some custom +# module, in the `exec` section to do the actual work. +method: legacy + + +# Human-visible strings in this module. These are all optional. +# The following translated keys are used: +# - *step*, used in the overall progress view (left-hand pane) +# +# Each key can have a [locale] added to it, which is used as +# the translated string for that locale. For the strings +# associated with the "no-selection" item, see *items*, below +# with the explicit item-*id* "". +# +labels: + step: "Packages" + step[nl]: "Pakketten" + +# (Optional) item-*id* of pre-selected list-view item. +# Pre-selects one of the items below. +# default: kde + +# Items to display in the chooser. In general, this should be a +# pretty short list to avoid overwhelming the UI. This is a list +# of objects, and the items are displayed in list order. +# +# Either provide the data for an item in the list (using the keys +# below), or use existing AppData XML files, or use AppStream cache +# as a source for the data. +# +# For data provided by the list: the item has an id, which is used in +# setting the value of *packagechooser_*. The following field +# is mandatory: +# +# - *id* +# ID for the product. The ID "" is special, and is used for +# "no package selected". Only include this if the mode allows +# selecting none. The name and description given for the "no package +# selected" item are displayed when the module starts. +# +# Each item must adhere to one of three "styles" of item. Which styles +# are supported depends on compile-time dependencies of Calamares. +# Both AppData and AppStream may **optionally** be available. +# +# # Generic Items # +# +# These items are always supported. They require the most configuration +# **in this file** and duplicate information that may be available elsewhere +# (e.g. in AppData or AppStream), but do not require any additional +# dependencies. These items have the following **mandatory** fields: +# +# - *name* +# Human-readable name of the product. To provide translations, +# add a *[lang]* decoration as part of the key name, e.g. `name[nl]` +# for Dutch. The list of usable languages can be found in +# `CMakeLists.txt` or as part of the debug output of Calamares. +# - *description* +# Human-readable description. These can be translated as well. +# - *screenshot* +# Path to a single screenshot of the product. May be a filesystem +# path or a QRC path, e.g. ":/images/no-selection.png". If the path +# is not found (e.g. is a non-existent absolute path, or is a relative +# path that does not exist in the current working directory) then +# an additional attempt is made to load the image from the **branding** +# directory. +# +# The following fields are **optional** for an item: +# +# - *packages* : +# List of package names for the product. If using the *method* +# "packages", consider this item mandatory (because otherwise +# selecting the item would install no packages). +# +# - *netinstall* : +# The data in this field should follow the format of a group +# from the netinstall module documented in +# src/modules/netinstall/netinstall.conf. This is only used +# when method is set to "netinstall-add" +# +# # AppData Items # +# +# For data provided by AppData XML: the item has an *appdata* +# key which points to an AppData XML file in the local filesystem. +# This file is parsed to provide the id (from AppData id), name +# (from AppData name), description (from AppData description paragraphs +# or the summary entries), and a screenshot (the default screenshot +# from AppData). No package is set (but that is unused anyway). +# +# AppData may contain IDs that are not useful inside Calamares, +# and the screenshot URL may be remote -- a remote URL will not +# be loaded and the screenshot will be missing. An item with *appdata* +# **may** specify an ID or screenshot path, as above. This will override +# the settings from AppData. +# +# # AppStream Items # +# +# For data provided by AppStream cache: the item has an *appstream* +# key which matches the AppStream identifier in the cache (e.g. +# *org.kde.kwrite.desktop*). Data is retrieved from the AppStream +# cache for that ID. The package name is set from the AppStream data. +# +# An item for AppStream may also contain an *id* and a *screenshot* +# key which will override the data from AppStream. +items: + - id: "" + # packages: [] # This item installs no packages + name: "No Desktop" + name[nl]: "Geen desktop" + description: "Please pick a desktop environment from the list. If you don't want to install a desktop, that's fine, your system will start up in text-only mode and you can install a desktop environment later." + description[nl]: "Kies eventueel een desktop-omgeving uit deze lijst. Als u geen desktop-omgeving wenst te gebruiken, kies er dan geen. In dat geval start het systeem straks op in tekst-modus en kunt u later alsnog een desktop-omgeving installeren." + screenshot: ":/images/no-selection.png" + - id: kde + packages: [ kde-frameworks, kde-plasma, kde-gear ] + name: Plasma Desktop + description: "KDE Plasma Desktop, simple by default, a clean work area for real-world usage which intends to stay out of your way. Plasma is powerful when needed, enabling the user to create the workflow that makes them more effective to complete their tasks." + screenshot: ":/images/kde.png" + - id: gnome + packages: [ gnome-all ] + name: GNOME + description: GNU Networked Object Modeling Environment Desktop + screenshot: ":/images/gnome.png" + - id: calamares + appdata: ../io.calamares.calamares.appdata.xml + screenshot: ":/images/calamares.png" + - id: kate + appstream: org.kde.kwrite.desktop diff --git a/calamares/src/modules/packagechooser/packagechooser.qrc b/calamares/src/modules/packagechooser/packagechooser.qrc new file mode 100644 index 0000000..3b9c96a --- /dev/null +++ b/calamares/src/modules/packagechooser/packagechooser.qrc @@ -0,0 +1,6 @@ + + + images/no-selection.png + images/calamares.png + + diff --git a/calamares/src/modules/packagechooser/page_package.ui b/calamares/src/modules/packagechooser/page_package.ui new file mode 100644 index 0000000..2ab5b7f --- /dev/null +++ b/calamares/src/modules/packagechooser/page_package.ui @@ -0,0 +1,104 @@ + + + +SPDX-FileCopyrightText: 2019 Adriaan de Groot <groot@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + PackageChooserPage + + + + 0 + 0 + 400 + 500 + + + + + 0 + 1 + + + + Form + + + + + + + + + 0 + 1 + + + + + + + + + + + 0 + 0 + + + + Product Name + + + + + + + + 1 + 1 + + + + TextLabel + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + Long Product Description + + + true + + + true + + + + + + + + + + + + FixedAspectRatioLabel + QLabel +
widgets/FixedAspectRatioLabel.h
+
+
+ + +
diff --git a/calamares/src/modules/packagechooserq/CMakeLists.txt b/calamares/src/modules/packagechooserq/CMakeLists.txt new file mode 100644 index 0000000..4646d8a --- /dev/null +++ b/calamares/src/modules/packagechooserq/CMakeLists.txt @@ -0,0 +1,56 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-FileCopyrightText: 2021 Anke Boersma +# SPDX-License-Identifier: BSD-2-Clause +# +if(NOT WITH_QML) + calamares_skip_module( "packagechooserq (QML is not supported in this build)" ) + return() +endif() + +find_package(${qtname} ${QT_VERSION} CONFIG REQUIRED Core) + +# include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/../packagechooser ) +set(_packagechooser ${CMAKE_CURRENT_SOURCE_DIR}/../packagechooser) + +### OPTIONAL AppData XML support in PackageModel +# +# +option(BUILD_APPDATA "Support appdata: items in PackageChooser (requires QtXml)" OFF) +if(BUILD_APPDATA) + find_package(${qtname} REQUIRED COMPONENTS Xml) +endif() + +### OPTIONAL AppStream support in PackageModel +# +# +include(AppStreamHelper) + +calamares_add_plugin(packagechooserq + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + PackageChooserQmlViewStep.cpp + ${_packagechooser}/Config.cpp + ${_packagechooser}/PackageModel.cpp + ${_extra_src} + RESOURCES + packagechooserq${QT_VERSION_SUFFIX}.qrc + LINK_PRIVATE_LIBRARIES + calamaresui + ${_extra_libraries} + SHARED_LIB +) +target_include_directories(${packagechooserq_TARGET} PRIVATE ${_packagechooser}) + +if(AppStreamQt_FOUND) + target_link_libraries(${packagechooserq_TARGET} PRIVATE calamares::appstreamqt) + target_sources(${packagechooserq_TARGET} PRIVATE ${_packagechooser}/ItemAppStream.cpp) +endif() + +if(BUILD_APPDATA AND TARGET ${qtname}::Xml) + target_compile_definitions(${packagechooserq_TARGET} PRIVATE HAVE_APPDATA) + target_link_libraries(${packagechooserq_TARGET} PRIVATE ${qtname}::Xml) + target_sources(${packagechooserq_TARGET} PRIVATE ${_packagechooser}/ItemAppData.cpp) +endif() diff --git a/calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.cpp b/calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.cpp new file mode 100644 index 0000000..e780b5e --- /dev/null +++ b/calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.cpp @@ -0,0 +1,86 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PackageChooserQmlViewStep.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "locale/TranslatableConfiguration.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Variant.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( PackageChooserQmlViewStepFactory, registerPlugin< PackageChooserQmlViewStep >(); ) + +PackageChooserQmlViewStep::PackageChooserQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) + , m_config( new Config( this ) ) +{ + emit nextStatusChanged( true ); +} + +QString +PackageChooserQmlViewStep::prettyName() const +{ + return m_config->prettyName(); +} + +QString +PackageChooserQmlViewStep::prettyStatus() const +{ + //QString option = m_pkgc; + //return tr( "Install option: %1" ).arg( option ); + return m_config->prettyStatus(); +} + +bool +PackageChooserQmlViewStep::isNextEnabled() const +{ + return true; +} + +bool +PackageChooserQmlViewStep::isBackEnabled() const +{ + return true; +} + +bool +PackageChooserQmlViewStep::isAtBeginning() const +{ + return true; +} + +bool +PackageChooserQmlViewStep::isAtEnd() const +{ + return true; +} + +Calamares::JobList +PackageChooserQmlViewStep::jobs() const +{ + Calamares::JobList l; + return l; +} + +void +PackageChooserQmlViewStep::onLeave() +{ + m_config->updateGlobalStorage(); +} + +void +PackageChooserQmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setDefaultId( moduleInstanceKey() ); + m_config->setConfigurationMap( configurationMap ); + Calamares::QmlViewStep::setConfigurationMap( configurationMap ); // call parent implementation last +} diff --git a/calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.h b/calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.h new file mode 100644 index 0000000..1ac2451 --- /dev/null +++ b/calamares/src/modules/packagechooserq/PackageChooserQmlViewStep.h @@ -0,0 +1,58 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PACKAGECHOOSERQMLVIEWSTEP_H +#define PACKAGECHOOSERQMLVIEWSTEP_H + +// Config from packagechooser module +#include "Config.h" + +#include "DllMacro.h" +#include "locale/TranslatableConfiguration.h" +#include "utils/PluginFactory.h" +#include "viewpages/QmlViewStep.h" + +#include + +class Config; +class PackageChooserPage; + +class PLUGINDLLEXPORT PackageChooserQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + explicit PackageChooserQmlViewStep( QObject* parent = nullptr ); + + QString prettyName() const override; + QString prettyStatus() const override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + //void onActivate() override; + void onLeave() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + QObject* getConfig() override { return m_config; } + +private: + Config* m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( PackageChooserQmlViewStepFactory ) + +#endif // PACKAGECHOOSERQMLVIEWSTEP_H diff --git a/calamares/src/modules/packagechooserq/images/libreoffice.jpg b/calamares/src/modules/packagechooserq/images/libreoffice.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e216cc77a3e2df33f37679daea382e1a827f640b GIT binary patch literal 47916 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/packagechooserq/images/libreoffice.jpg.license b/calamares/src/modules/packagechooserq/images/libreoffice.jpg.license new file mode 100644 index 0000000..5f43e65 --- /dev/null +++ b/calamares/src/modules/packagechooserq/images/libreoffice.jpg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2020 demmm +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/packagechooserq/images/no-selection.png b/calamares/src/modules/packagechooserq/images/no-selection.png new file mode 100644 index 0000000000000000000000000000000000000000..b32d2170215e9337a31614c26a6b60b5f11426f9 GIT binary patch literal 188248 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 z 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

    Kpsl1eXLr6 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 Vl||K8re**D002ovPDHLkV1n&~wVMC{ literal 0 HcmV?d00001 diff --git a/calamares/src/modules/packagechooserq/images/no-selection.png.license b/calamares/src/modules/packagechooserq/images/no-selection.png.license new file mode 100644 index 0000000..5f43e65 --- /dev/null +++ b/calamares/src/modules/packagechooserq/images/no-selection.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2020 demmm +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/packagechooserq/images/plasma.png b/calamares/src/modules/packagechooserq/images/plasma.png new file mode 100644 index 0000000000000000000000000000000000000000..e9e35a5285e7a6b421ac0693181c957794c2b587 GIT binary patch literal 35256 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 V1#h=Y2QU&7C%BD2KF(|Pe*rwi%gO)% literal 0 HcmV?d00001 diff --git a/calamares/src/modules/packagechooserq/images/plasma.png.license b/calamares/src/modules/packagechooserq/images/plasma.png.license new file mode 100644 index 0000000..16c11f0 --- /dev/null +++ b/calamares/src/modules/packagechooserq/images/plasma.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021 pngegg +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/packagechooserq/packagechooserq-qt6.qml b/calamares/src/modules/packagechooserq/packagechooserq-qt6.qml new file mode 100644 index 0000000..d951a2e --- /dev/null +++ b/calamares/src/modules/packagechooserq/packagechooserq-qt6.qml @@ -0,0 +1,241 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + width: parent.width + height: parent.height + + Rectangle { + anchors.fill: parent + color: "#f2f2f2" + + ButtonGroup { + id: switchGroup + } + + Column { + id: column + anchors.centerIn: parent + spacing: 5 + + Rectangle { + //id: rectangle + width: 700 + height: 150 + color: "#ffffff" + radius: 10 + border.width: 0 + Text { + width: 450 + height: 104 + anchors.centerIn: parent + text: qsTr("LibreOffice is a powerful and free office suite, used by millions of people around the world. It includes several applications that make it the most versatile Free and Open Source office suite on the market.
    + Default option.") + font.pointSize: 10 + anchors.verticalCenterOffset: -10 + anchors.horizontalCenterOffset: 100 + wrapMode: Text.WordWrap + } + + Switch { + id: element2 + x: 500 + y: 110 + width: 187 + height: 14 + text: qsTr("LibreOffice") + checked: true + hoverEnabled: true + ButtonGroup.group: switchGroup + + indicator: Rectangle { + implicitWidth: 40 + implicitHeight: 14 + radius: 10 + color: element2.checked ? "#3498db" : "#B9B9B9" + border.color: element2.checked ? "#3498db" : "#cccccc" + + Rectangle { + x: element2.checked ? parent.width - width : 0 + y: (parent.height - height) / 2 + width: 20 + height: 20 + radius: 10 + color: element2.down ? "#cccccc" : "#ffffff" + border.color: element2.checked ? (element1.down ? "#3498db" : "#3498db") : "#999999" + } + } + + onCheckedChanged: { + if ( checked ) { + config.packageChoice = "libreoffice" + } + } + } + + Image { + id: image2 + x: 8 + y: 25 + height: 100 + fillMode: Image.PreserveAspectFit + source: "images/libreoffice.jpg" + } + } + + Rectangle { + width: 700 + height: 150 + radius: 10 + border.width: 0 + Text { + width: 450 + height: 104 + anchors.centerIn: parent + text: qsTr("If you don't want to install an office suite, just select No Office Suite. You can always add one (or more) later on your installed system as the need arrives.") + font.pointSize: 10 + anchors.verticalCenterOffset: -10 + anchors.horizontalCenterOffset: 100 + wrapMode: Text.WordWrap + } + + Switch { + id: element1 + x: 500 + y: 110 + width: 187 + height: 14 + text: qsTr("No Office Suite") + checked: false + hoverEnabled: true + ButtonGroup.group: switchGroup + + indicator: Rectangle { + implicitWidth: 40 + implicitHeight: 14 + radius: 10 + color: element1.checked ? "#3498db" : "#B9B9B9" + border.color: element1.checked ? "#3498db" : "#cccccc" + + Rectangle { + x: element1.checked ? parent.width - width : 0 + y: (parent.height - height) / 2 + width: 20 + height: 20 + radius: 10 + color: element1.down ? "#cccccc" : "#ffffff" + border.color: element1.checked ? (element1.down ? "#3498db" : "#3498db") : "#999999" + } + } + + onCheckedChanged: { + if ( checked ) { + config.packageChoice = "no_office_suite" + } + } + } + + Image { + id: image + x: 8 + y: 25 + height: 100 + fillMode: Image.PreserveAspectFit + source: "images/no-selection.png" + } + + } + + Rectangle { + width: 700 + height: 150 + color: "#ffffff" + radius: 10 + border.width: 0 + Text { + width: 450 + height: 104 + anchors.centerIn: parent + text: qsTr("Create a minimal Desktop install, remove all extra applications and decide later on what you would like to add to your system. Examples of what won't be on such an install, there will be no Office Suite, no media players, no image viewer or print support. It will be just a desktop, file browser, package manager, text editor and simple web-browser.") + font.pointSize: 10 + anchors.verticalCenterOffset: -10 + anchors.horizontalCenterOffset: 100 + wrapMode: Text.WordWrap + } + + Switch { + id: element3 + x: 500 + y: 110 + width: 187 + height: 14 + text: qsTr("Minimal Install") + checked: false + hoverEnabled: true + ButtonGroup.group: switchGroup + + indicator: Rectangle { + implicitWidth: 40 + implicitHeight: 14 + radius: 10 + color: element3.checked ? "#3498db" : "#B9B9B9" + border.color: element3.checked ? "#3498db" : "#cccccc" + + Rectangle { + x: element3.checked ? parent.width - width : 0 + y: (parent.height - height) / 2 + width: 20 + height: 20 + radius: 10 + color: element3.down ? "#cccccc" : "#ffffff" + border.color: element3.checked ? (element3.down ? "#3498db" : "#3498db") : "#999999" + } + } + + onCheckedChanged: { + if ( checked ) { + config.packageChoice = "minimal_install" + } + } + } + + Image { + id: image3 + x: 8 + y: 25 + height: 100 + fillMode: Image.PreserveAspectFit + source: "images/plasma.png" + } + } + + Rectangle { + width: 700 + height: 25 + color: "#f2f2f2" + border.width: 0 + Text { + height: 25 + anchors.centerIn: parent + text: qsTr("Please select an option for your install, or use the default: LibreOffice included.") + font.pointSize: 10 + wrapMode: Text.WordWrap + } + } + } + } + +} diff --git a/calamares/src/modules/packagechooserq/packagechooserq-qt6.qrc b/calamares/src/modules/packagechooserq/packagechooserq-qt6.qrc new file mode 100644 index 0000000..d243f93 --- /dev/null +++ b/calamares/src/modules/packagechooserq/packagechooserq-qt6.qrc @@ -0,0 +1,8 @@ + + + packagechooserq-qt6.qml + images/libreoffice.jpg + images/no-selection.png + images/plasma.png + + diff --git a/calamares/src/modules/packagechooserq/packagechooserq.conf b/calamares/src/modules/packagechooserq/packagechooserq.conf new file mode 100644 index 0000000..9c1878f --- /dev/null +++ b/calamares/src/modules/packagechooserq/packagechooserq.conf @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the low-density software chooser, QML implementation +# +# The example QML implementation uses single-selection, rather than +# a model for the available packages. That makes it simpler: the +# QML itself codes the available options, descriptions and images +# -- after all, this is **low density** selection, so a custom UI +# can make sense for the few choices that need to be made. +# +# + +--- +# Software installation method: +# +# - "legacy" or "custom" or "contextualprocess" +# When set to "legacy", writes a GlobalStorage value for the choice that +# has been made. The key is *packagechooser_*. The module's +# instance name is used; see the *instances* section of `settings.conf`. +# If there is just one packagechooserq module, and no special instance is set, +# resulting GS key is probably *packagechooser_packagechooserq*. +# (Do note that the prefix of the GS key remains "packagechooser_") +# +# The GS value is a comma-separated list of the IDs of the selected +# packages, or an empty string if none is selected. +# +# With "legacy" installation, you should have a contextualprocess or similar +# module somewhere in the `exec` phase to process the GlobalStorage key +# and actually **do** something for the packages. +# +# - "packages" +# When set to "packages", writes GlobalStorage values suitable for +# consumption by the *packages* module (which should appear later +# in the `exec` section. These package settings will then be handed +# off to whatever package manager is configured there. +# +# There is no need to put this module in the `exec` section. There +# are no jobs that this module provides. You should put **other** +# modules, either *contextualprocess* or *packages* or some custom +# module, in the `exec` section to do the actual work. +# +method: legacy + +# Human-visible strings in this module. These are all optional. +# The following translated keys are used: +# - *step*, used in the overall progress view (left-hand pane) +# +# Each key can have a [locale] added to it, which is used as +# the translated string for that locale. For the strings +# associated with the "no-selection" item, see *items*, below +# with the explicit item-*id* "". +# +labels: + step: "Packages" + step[nl]: "Pakketten" + +# The *packageChoice* value is used for setting the default selection +# in the QML view; this should match one of the keys used in the QML +# module for package names. +# +# (e.g. the sample QML uses "no_office_suite", "minimal_install" and +# "libreoffice" as possible choices). +# +packageChoice: libreoffice + diff --git a/calamares/src/modules/packagechooserq/packagechooserq.qml b/calamares/src/modules/packagechooserq/packagechooserq.qml new file mode 100644 index 0000000..7d16135 --- /dev/null +++ b/calamares/src/modules/packagechooserq/packagechooserq.qml @@ -0,0 +1,241 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.3 + +Item { + width: parent.width + height: parent.height + + Rectangle { + anchors.fill: parent + color: "#f2f2f2" + + ButtonGroup { + id: switchGroup + } + + Column { + id: column + anchors.centerIn: parent + spacing: 5 + + Rectangle { + //id: rectangle + width: 700 + height: 150 + color: "#ffffff" + radius: 10 + border.width: 0 + Text { + width: 450 + height: 104 + anchors.centerIn: parent + text: qsTr("LibreOffice is a powerful and free office suite, used by millions of people around the world. It includes several applications that make it the most versatile Free and Open Source office suite on the market.
    + Default option.") + font.pointSize: 10 + anchors.verticalCenterOffset: -10 + anchors.horizontalCenterOffset: 100 + wrapMode: Text.WordWrap + } + + Switch { + id: element2 + x: 500 + y: 110 + width: 187 + height: 14 + text: qsTr("LibreOffice") + checked: true + hoverEnabled: true + ButtonGroup.group: switchGroup + + indicator: Rectangle { + implicitWidth: 40 + implicitHeight: 14 + radius: 10 + color: element2.checked ? "#3498db" : "#B9B9B9" + border.color: element2.checked ? "#3498db" : "#cccccc" + + Rectangle { + x: element2.checked ? parent.width - width : 0 + y: (parent.height - height) / 2 + width: 20 + height: 20 + radius: 10 + color: element2.down ? "#cccccc" : "#ffffff" + border.color: element2.checked ? (element1.down ? "#3498db" : "#3498db") : "#999999" + } + } + + onCheckedChanged: { + if ( checked ) { + config.packageChoice = "libreoffice" + } + } + } + + Image { + id: image2 + x: 8 + y: 25 + height: 100 + fillMode: Image.PreserveAspectFit + source: "images/libreoffice.jpg" + } + } + + Rectangle { + width: 700 + height: 150 + radius: 10 + border.width: 0 + Text { + width: 450 + height: 104 + anchors.centerIn: parent + text: qsTr("If you don't want to install an office suite, just select No Office Suite. You can always add one (or more) later on your installed system as the need arrives.") + font.pointSize: 10 + anchors.verticalCenterOffset: -10 + anchors.horizontalCenterOffset: 100 + wrapMode: Text.WordWrap + } + + Switch { + id: element1 + x: 500 + y: 110 + width: 187 + height: 14 + text: qsTr("No Office Suite") + checked: false + hoverEnabled: true + ButtonGroup.group: switchGroup + + indicator: Rectangle { + implicitWidth: 40 + implicitHeight: 14 + radius: 10 + color: element1.checked ? "#3498db" : "#B9B9B9" + border.color: element1.checked ? "#3498db" : "#cccccc" + + Rectangle { + x: element1.checked ? parent.width - width : 0 + y: (parent.height - height) / 2 + width: 20 + height: 20 + radius: 10 + color: element1.down ? "#cccccc" : "#ffffff" + border.color: element1.checked ? (element1.down ? "#3498db" : "#3498db") : "#999999" + } + } + + onCheckedChanged: { + if ( checked ) { + config.packageChoice = "no_office_suite" + } + } + } + + Image { + id: image + x: 8 + y: 25 + height: 100 + fillMode: Image.PreserveAspectFit + source: "images/no-selection.png" + } + + } + + Rectangle { + width: 700 + height: 150 + color: "#ffffff" + radius: 10 + border.width: 0 + Text { + width: 450 + height: 104 + anchors.centerIn: parent + text: qsTr("Create a minimal Desktop install, remove all extra applications and decide later on what you would like to add to your system. Examples of what won't be on such an install, there will be no Office Suite, no media players, no image viewer or print support. It will be just a desktop, file browser, package manager, text editor and simple web-browser.") + font.pointSize: 10 + anchors.verticalCenterOffset: -10 + anchors.horizontalCenterOffset: 100 + wrapMode: Text.WordWrap + } + + Switch { + id: element3 + x: 500 + y: 110 + width: 187 + height: 14 + text: qsTr("Minimal Install") + checked: false + hoverEnabled: true + ButtonGroup.group: switchGroup + + indicator: Rectangle { + implicitWidth: 40 + implicitHeight: 14 + radius: 10 + color: element3.checked ? "#3498db" : "#B9B9B9" + border.color: element3.checked ? "#3498db" : "#cccccc" + + Rectangle { + x: element3.checked ? parent.width - width : 0 + y: (parent.height - height) / 2 + width: 20 + height: 20 + radius: 10 + color: element3.down ? "#cccccc" : "#ffffff" + border.color: element3.checked ? (element3.down ? "#3498db" : "#3498db") : "#999999" + } + } + + onCheckedChanged: { + if ( checked ) { + config.packageChoice = "minimal_install" + } + } + } + + Image { + id: image3 + x: 8 + y: 25 + height: 100 + fillMode: Image.PreserveAspectFit + source: "images/plasma.png" + } + } + + Rectangle { + width: 700 + height: 25 + color: "#f2f2f2" + border.width: 0 + Text { + height: 25 + anchors.centerIn: parent + text: qsTr("Please select an option for your install, or use the default: LibreOffice included.") + font.pointSize: 10 + wrapMode: Text.WordWrap + } + } + } + } + +} diff --git a/calamares/src/modules/packagechooserq/packagechooserq.qrc b/calamares/src/modules/packagechooserq/packagechooserq.qrc new file mode 100644 index 0000000..1b892dc --- /dev/null +++ b/calamares/src/modules/packagechooserq/packagechooserq.qrc @@ -0,0 +1,8 @@ + + + packagechooserq.qml + images/libreoffice.jpg + images/no-selection.png + images/plasma.png + + diff --git a/calamares/src/modules/packages/main.py b/calamares/src/modules/packages/main.py new file mode 100644 index 0000000..b1aa6e3 --- /dev/null +++ b/calamares/src/modules/packages/main.py @@ -0,0 +1,823 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Pier Luigi Fiorini +# SPDX-FileCopyrightText: 2015-2017 Teo Mrnjavac +# SPDX-FileCopyrightText: 2016-2017 Kyle Robbertze +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2018 Adriaan de Groot +# SPDX-FileCopyrightText: 2018 Philip Müller +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import abc +from string import Template +import subprocess + +import libcalamares +from libcalamares.utils import check_target_env_call, target_env_call +from libcalamares.utils import gettext_path, gettext_languages + +import gettext +_translation = gettext.translation("calamares-python", + localedir=gettext_path(), + languages=gettext_languages(), + fallback=True) +_ = _translation.gettext +_n = _translation.ngettext + + +total_packages = 0 # For the entire job +completed_packages = 0 # Done so far for this job +group_packages = 0 # One group of packages from an -install or -remove entry + +# A PM object may set this to a string (take care of translations!) +# to override the string produced by pretty_status_message() +custom_status_message = None + +INSTALL = object() +REMOVE = object() +mode_packages = None # Changes to INSTALL or REMOVE + + +def _change_mode(mode): + global mode_packages + mode_packages = mode + libcalamares.job.setprogress(completed_packages * 1.0 / total_packages) + + +def pretty_name(): + return _("Install packages.") + + +def pretty_status_message(): + if custom_status_message is not None: + return custom_status_message + if not group_packages: + if (total_packages > 0): + # Outside the context of an operation + s = _("Processing packages (%(count)d / %(total)d)") + else: + s = _("Install packages.") + + elif mode_packages is INSTALL: + s = _n("Installing one package.", + "Installing %(num)d packages.", group_packages) + elif mode_packages is REMOVE: + s = _n("Removing one package.", + "Removing %(num)d packages.", group_packages) + else: + # No mode, generic description + s = _("Install packages.") + + return s % {"num": group_packages, + "count": completed_packages, + "total": total_packages} + + +class PackageManager(metaclass=abc.ABCMeta): + """ + Package manager base class. A subclass implements package management + for a specific backend, and must have a class property `backend` + with the string identifier for that backend. + + Subclasses are collected below to populate the list of possible + backends. + """ + backend = None + + @abc.abstractmethod + def install(self, pkgs, from_local=False): + """ + Install a list of packages (named) into the system. + Although this handles lists, in practice it is called + with one package at a time. + + @param pkgs: list[str] + list of package names + @param from_local: bool + if True, then these are local packages (on disk) and the + pkgs names are paths. + """ + pass + + @abc.abstractmethod + def remove(self, pkgs): + """ + Removes packages. + + @param pkgs: list[str] + list of package names + """ + pass + + @abc.abstractmethod + def update_db(self): + pass + + def run(self, script): + if script != "": + check_target_env_call(script.split(" ")) + + def install_package(self, packagedata, from_local=False): + """ + Install a package from a single entry in the install list. + This can be either a single package name, or an object + with pre- and post-scripts. If @p packagedata is a dict, + it is assumed to follow the documented structure. + + @param packagedata: str|dict + @param from_local: bool + see install.from_local + """ + if isinstance(packagedata, str): + self.install([packagedata], from_local=from_local) + else: + self.run(packagedata["pre-script"]) + self.install([packagedata["package"]], from_local=from_local) + self.run(packagedata["post-script"]) + + def remove_package(self, packagedata): + """ + Remove a package from a single entry in the remove list. + This can be either a single package name, or an object + with pre- and post-scripts. If @p packagedata is a dict, + it is assumed to follow the documented structure. + + @param packagedata: str|dict + """ + if isinstance(packagedata, str): + self.remove([packagedata]) + else: + self.run(packagedata["pre-script"]) + self.remove([packagedata["package"]]) + self.run(packagedata["post-script"]) + + def operation_install(self, package_list, from_local=False): + """ + Installs the list of packages named in @p package_list . + These can be strings -- plain package names -- or + structures (with a pre- and post-install step). + + This operation is called for "critical" packages, + which are expected to succeed, or fail, all together. + However, if there are packages with pre- or post-scripts, + then packages are installed one-by-one instead. + + NOTE: package managers may reimplement this method + NOTE: exceptions are expected to leave this method, to indicate + failure of the installation. + """ + if all([isinstance(x, str) for x in package_list]): + self.install(package_list, from_local=from_local) + else: + for package in package_list: + self.install_package(package, from_local=from_local) + + def operation_try_install(self, package_list): + """ + Installs the list of packages named in @p package_list . + These can be strings -- plain package names -- or + structures (with a pre- and post-install step). + + This operation is called for "non-critical" packages, + which can succeed or fail without affecting the overall installation. + Packages are installed one-by-one to support package managers + that do not have a "install as much as you can" mode. + + NOTE: package managers may reimplement this method + NOTE: no package-installation exceptions should be raised + """ + # we make a separate package manager call for each package so a + # single failing package won't stop all of them + for package in package_list: + try: + self.install_package(package) + except subprocess.CalledProcessError: + libcalamares.utils.warning("Could not install package %s" % package) + + def operation_remove(self, package_list): + """ + Removes the list of packages named in @p package_list . + These can be strings -- plain package names -- or + structures (with a pre- and post-install step). + + This operation is called for "critical" packages, which are + expected to succeed or fail all together. + However, if there are packages with pre- or post-scripts, + then packages are removed one-by-one instead. + + NOTE: package managers may reimplement this method + NOTE: exceptions should be raised to indicate failure + """ + if all([isinstance(x, str) for x in package_list]): + self.remove(package_list) + else: + for package in package_list: + self.remove_package(package) + + def operation_try_remove(self, package_list): + """ + Same relation as try_install has to install, except it removes + packages instead. Packages are removed one-by-one. + + NOTE: package managers may reimplement this method + NOTE: no package-installation exceptions should be raised + """ + for package in package_list: + try: + self.remove_package(package) + except subprocess.CalledProcessError: + libcalamares.utils.warning("Could not remove package %s" % package) + +### PACKAGE MANAGER IMPLEMENTATIONS +# +# Keep these alphabetical (presumably both by class name and backend name), +# even the Dummy implementation. +# + +class PMApk(PackageManager): + backend = "apk" + + def install(self, pkgs, from_local=False): + for pkg in pkgs: + check_target_env_call(["apk", "add", pkg]) + + def remove(self, pkgs): + for pkg in pkgs: + check_target_env_call(["apk", "del", pkg]) + + def update_db(self): + check_target_env_call(["apk", "update"]) + + def update_system(self): + check_target_env_call(["apk", "upgrade", "--available"]) + + +class PMApt(PackageManager): + backend = "apt" + + def install(self, pkgs, from_local=False): + check_target_env_call(["apt-get", "-q", "-y", "install"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["apt-get", "--purge", "-q", "-y", + "remove"] + pkgs) + check_target_env_call(["apt-get", "--purge", "-q", "-y", + "autoremove"]) + + def update_db(self): + check_target_env_call(["apt-get", "update"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +class PMDnf(PackageManager): + """ + This is "legacy" DNF, called DNF-4 even though the + executable is dnf-3 in modern Fedora. Executable dnf + is a symlink to dnf-3 in systems that use it. + """ + backend = "dnf" + + def install(self, pkgs, from_local=False): + check_target_env_call(["dnf-3", "-y", "install"] + pkgs) + + def remove(self, pkgs): + # ignore the error code for now because dnf thinks removing a + # nonexistent package is an error + target_env_call(["dnf-3", "--disablerepo=*", "-C", "-y", + "remove"] + pkgs) + + def update_db(self): + # Doesn't need updates + pass + + def update_system(self): + check_target_env_call(["dnf-3", "-y", "upgrade"]) + + +class PMDnf5(PackageManager): + """ + This is "modern" DNF, DNF-5 which is for Fedora 41 (presumably) + and later. Executable dnf is a symlink to dnf5 in systems that use it. + """ + backend = "dnf5" + + def install(self, pkgs, from_local=False): + check_target_env_call(["dnf5", "-y", "install"] + pkgs) + + def remove(self, pkgs): + # ignore the error code for now because dnf thinks removing a + # nonexistent package is an error + target_env_call(["dnf5", "--disablerepo=*", "-C", "-y", + "remove"] + pkgs) + + def update_db(self): + # Doesn't need updates + pass + + def update_system(self): + check_target_env_call(["dnf5", "-y", "upgrade"]) + + +class PMDummy(PackageManager): + backend = "dummy" + + def install(self, pkgs, from_local=False): + from time import sleep + libcalamares.utils.debug("Dummy backend: Installing " + str(pkgs)) + sleep(3) + + def remove(self, pkgs): + from time import sleep + libcalamares.utils.debug("Dummy backend: Removing " + str(pkgs)) + sleep(3) + + def update_db(self): + libcalamares.utils.debug("Dummy backend: Updating DB") + + def update_system(self): + libcalamares.utils.debug("Dummy backend: Updating System") + + def run(self, script): + libcalamares.utils.debug("Dummy backend: Running script '" + str(script) + "'") + + +class PMEntropy(PackageManager): + backend = "entropy" + + def install(self, pkgs, from_local=False): + check_target_env_call(["equo", "i"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["equo", "rm"] + pkgs) + + def update_db(self): + check_target_env_call(["equo", "update"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + +class PMFlatpak(PackageManager): + backend = "flatpak" + + def install(self, pkgs, from_local=False): + check_target_env_call(["flatpak", "install", "--assumeyes"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["flatpak", "uninstall", "--noninteractive"] + pkgs) + + def update_db(self): + pass + + def update_system(self): + # Doesn't need to update the system explicitly + pass + +class PMLuet(PackageManager): + backend = "luet" + + def install(self, pkgs, from_local=False): + check_target_env_call(["luet", "install", "-y"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["luet", "uninstall", "-y"] + pkgs) + + def update_db(self): + # Luet checks for DB update everytime its ran. + pass + + def update_system(self): + check_target_env_call(["luet", "upgrade", "-y"]) + + +class PMPackageKit(PackageManager): + backend = "packagekit" + + def install(self, pkgs, from_local=False): + for pkg in pkgs: + check_target_env_call(["pkcon", "-py", "install", pkg]) + + def remove(self, pkgs): + for pkg in pkgs: + check_target_env_call(["pkcon", "-py", "remove", pkg]) + + def update_db(self): + check_target_env_call(["pkcon", "refresh"]) + + def update_system(self): + check_target_env_call(["pkcon", "-py", "update"]) + + +class PMPacman(PackageManager): + backend = "pacman" + + def __init__(self): + import re + progress_match = re.compile("^\\((\\d+)/(\\d+)\\)") + + def line_cb(line): + if line.startswith(":: "): + self.in_package_changes = "package" in line or "hooks" in line + else: + if self.in_package_changes and line.endswith("...\n"): + # Update the message, untranslated; do not change the + # progress percentage, since there may be more "installing..." + # lines in the output for the group, than packages listed + # explicitly. We don't know how to calculate proper progress. + global custom_status_message + custom_status_message = "pacman: " + line.strip() + libcalamares.job.setprogress(self.progress_fraction) + libcalamares.utils.debug(line) + + self.in_package_changes = False + self.line_cb = line_cb + + pacman = libcalamares.job.configuration.get("pacman", None) + if pacman is None: + pacman = dict() + if type(pacman) is not dict: + libcalamares.utils.warning("Job configuration *pacman* will be ignored.") + pacman = dict() + self.pacman_num_retries = pacman.get("num_retries", 0) + self.pacman_disable_timeout = pacman.get("disable_download_timeout", False) + self.pacman_needed_only = pacman.get("needed_only", False) + + def reset_progress(self): + self.in_package_changes = False + # These are globals + self.progress_fraction = (completed_packages * 1.0 / total_packages) + + def run_pacman(self, command, callback=False): + """ + Call pacman in a loop until it is successful or the number of retries is exceeded + :param command: The pacman command to run + :param callback: An optional boolean that indicates if this pacman run should use the callback + :return: + """ + + pacman_count = 0 + while pacman_count <= self.pacman_num_retries: + pacman_count += 1 + try: + if False: # callback: + libcalamares.utils.target_env_process_output(command, self.line_cb) + else: + libcalamares.utils.target_env_process_output(command) + + return + except subprocess.CalledProcessError: + if pacman_count <= self.pacman_num_retries: + pass + else: + raise + + def install(self, pkgs, from_local=False): + command = ["pacman"] + + if from_local: + command.append("-U") + else: + command.append("-S") + + # Don't ask for user intervention, take the default action + command.append("--noconfirm") + + # Don't report download progress for each file + command.append("--noprogressbar") + + if self.pacman_needed_only is True: + command.append("--needed") + + if self.pacman_disable_timeout is True: + command.append("--disable-download-timeout") + + command += pkgs + + self.reset_progress() + self.run_pacman(command, True) + + def remove(self, pkgs): + self.reset_progress() + self.run_pacman(["pacman", "-Rs", "--noconfirm"] + pkgs, True) + + def update_db(self): + self.run_pacman(["pacman", "-Sy"]) + + def update_system(self): + command = ["pacman", "-Su", "--noconfirm"] + if self.pacman_disable_timeout is True: + command.append("--disable-download-timeout") + + self.run_pacman(command) + + +class PMPamac(PackageManager): + backend = "pamac" + + def del_db_lock(self, lock="/var/lib/pacman/db.lck"): + # In case some error or crash, the database will be locked, + # resulting in remaining packages not being installed. + check_target_env_call(["rm", "-f", lock]) + + def install(self, pkgs, from_local=False): + self.del_db_lock() + check_target_env_call([self.backend, "install", "--no-confirm"] + pkgs) + + def remove(self, pkgs): + self.del_db_lock() + check_target_env_call([self.backend, "remove", "--no-confirm"] + pkgs) + + def update_db(self): + self.del_db_lock() + check_target_env_call([self.backend, "update", "--no-confirm"]) + + def update_system(self): + self.del_db_lock() + check_target_env_call([self.backend, "upgrade", "--no-confirm"]) + + +class PMPisi(PackageManager): + backend = "pisi" + + def install(self, pkgs, from_local=False): + check_target_env_call(["pisi", "install" "-y"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["pisi", "remove", "-y"] + pkgs) + + def update_db(self): + check_target_env_call(["pisi", "update-repo"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +class PMPortage(PackageManager): + backend = "portage" + + def install(self, pkgs, from_local=False): + check_target_env_call(["emerge", "-v"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["emerge", "-C"] + pkgs) + check_target_env_call(["emerge", "--depclean", "-q"]) + + def update_db(self): + check_target_env_call(["emerge", "--sync"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +class PMXbps(PackageManager): + backend = "xbps" + + def line_cb(self, line): + libcalamares.utils.debug(line) + + def run_xbps(self, command): + libcalamares.utils.target_env_process_output(command, self.line_cb); + + def install(self, pkgs, from_local=False): + self.run_xbps(["xbps-install", "-Sy"] + pkgs) + + def remove(self, pkgs): + self.run_xbps(["xbps-remove", "-Ry"] + pkgs) + + def update_db(self): + self.run_xbps(["xbps-install", "-S"]) + + def update_system(self): + self.run_xbps(["xbps", "-Suy"]) + + +class PMYum(PackageManager): + backend = "yum" + + def install(self, pkgs, from_local=False): + check_target_env_call(["yum", "-y", "install"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["yum", "--disablerepo=*", "-C", "-y", + "remove"] + pkgs) + + def update_db(self): + # Doesn't need updates + pass + + def update_system(self): + check_target_env_call(["yum", "-y", "upgrade"]) + + +class PMZypp(PackageManager): + backend = "zypp" + + def install(self, pkgs, from_local=False): + check_target_env_call(["zypper", "--non-interactive", + "--quiet-install", "install", + "--auto-agree-with-licenses", + "install"] + pkgs) + + def remove(self, pkgs): + check_target_env_call(["zypper", "--non-interactive", + "remove"] + pkgs) + + def update_db(self): + check_target_env_call(["zypper", "--non-interactive", "update"]) + + def update_system(self): + # Doesn't need to update the system explicitly + pass + + +# Collect all the subclasses of PackageManager defined above, +# and index them based on the backend property of each class. +backend_managers = [ + (c.backend, c) + for c in globals().values() + if type(c) is abc.ABCMeta and issubclass(c, PackageManager) and c.backend] + + +def subst_locale(plist): + """ + Returns a locale-aware list of packages, based on @p plist. + Package names that contain LOCALE are localized with the + BCP47 name of the chosen system locale; if the system + locale is 'en' (e.g. English, US) then these localized + packages are dropped from the list. + + @param plist: list[str|dict] + Candidate packages to install. + @return: list[str|dict] + """ + locale = libcalamares.globalstorage.value("locale") + if not locale: + # It is possible to skip the locale-setting entirely. + # Then pretend it is "en", so that {LOCALE}-decorated + # package names are removed from the list. + locale = "en" + + ret = [] + for packagedata in plist: + if isinstance(packagedata, str): + packagename = packagedata + else: + packagename = packagedata["package"] + + # Update packagename: substitute LOCALE, and drop packages + # if locale is en and LOCALE is in the package name. + if locale != "en": + packagename = Template(packagename).safe_substitute(LOCALE=locale) + elif 'LOCALE' in packagename: + packagename = None + + if packagename is not None: + # Put it back in packagedata + if isinstance(packagedata, str): + packagedata = packagename + else: + packagedata["package"] = packagename + + ret.append(packagedata) + + return ret + + +def run_operations(pkgman, entry): + """ + Call package manager with suitable parameters for the given + package actions. + + :param pkgman: PackageManager + This is the manager that does the actual work. + :param entry: dict + Keys are the actions -- e.g. "install" -- to take, and the values + are the (list of) packages to apply the action to. The actions are + not iterated in a specific order, so it is recommended to use only + one action per dictionary. The list of packages may be package + names (strings) or package information dictionaries with pre- + and post-scripts. + """ + global group_packages, completed_packages, mode_packages + + for key in entry.keys(): + package_list = subst_locale(entry[key]) + group_packages = len(package_list) + if key == "install": + _change_mode(INSTALL) + pkgman.operation_install(package_list) + elif key == "try_install": + _change_mode(INSTALL) + pkgman.operation_try_install(package_list) + elif key == "remove": + _change_mode(REMOVE) + pkgman.operation_remove(package_list) + elif key == "try_remove": + _change_mode(REMOVE) + pkgman.operation_try_remove(package_list) + elif key == "localInstall": + _change_mode(INSTALL) + pkgman.operation_install(package_list, from_local=True) + elif key == "source": + libcalamares.utils.debug("Package-list from {!s}".format(entry[key])) + else: + libcalamares.utils.warning("Unknown package-operation key {!s}".format(key)) + completed_packages += len(package_list) + libcalamares.job.setprogress(completed_packages * 1.0 / total_packages) + libcalamares.utils.debug("Pretty name: {!s}, setting progress..".format(pretty_name())) + + group_packages = 0 + _change_mode(None) + + +def run(): + """ + Calls routine with detected package manager to install locale packages + or remove drivers not needed on the installed system. + + :return: + """ + global mode_packages, total_packages, completed_packages, group_packages + + backend = libcalamares.job.configuration.get("backend") + + for identifier, impl in backend_managers: + if identifier == backend: + pkgman = impl() + break + else: + return "Bad backend", "backend=\"{}\"".format(backend) + + skip_this = libcalamares.job.configuration.get("skip_if_no_internet", False) + if skip_this and not libcalamares.globalstorage.value("hasInternet"): + libcalamares.utils.warning( "Package installation has been skipped: no internet" ) + return None + + update_db = libcalamares.job.configuration.get("update_db", False) + if update_db and libcalamares.globalstorage.value("hasInternet"): + try: + pkgman.update_db() + except subprocess.CalledProcessError as e: + libcalamares.utils.warning(str(e)) + libcalamares.utils.debug("stdout:" + str(e.stdout)) + libcalamares.utils.debug("stderr:" + str(e.stderr)) + return (_("Package Manager error"), + _("The package manager could not prepare updates. The command

    {!s}
    returned error code {!s}.") + .format(e.cmd, e.returncode)) + + update_system = libcalamares.job.configuration.get("update_system", False) + if update_system and libcalamares.globalstorage.value("hasInternet"): + try: + pkgman.update_system() + except subprocess.CalledProcessError as e: + libcalamares.utils.warning(str(e)) + libcalamares.utils.debug("stdout:" + str(e.stdout)) + libcalamares.utils.debug("stderr:" + str(e.stderr)) + return (_("Package Manager error"), + _("The package manager could not update the system. The command
    {!s}
    returned error code {!s}.") + .format(e.cmd, e.returncode)) + + operations = libcalamares.job.configuration.get("operations", []) + if libcalamares.globalstorage.contains("packageOperations"): + operations += libcalamares.globalstorage.value("packageOperations") + + mode_packages = None + total_packages = 0 + completed_packages = 0 + for op in operations: + for packagelist in op.values(): + total_packages += len(subst_locale(packagelist)) + + if not total_packages: + # Avoids potential divide-by-zero in progress reporting + return None + + for entry in operations: + group_packages = 0 + libcalamares.utils.debug(pretty_name()) + try: + run_operations(pkgman, entry) + except subprocess.CalledProcessError as e: + libcalamares.utils.warning(str(e)) + libcalamares.utils.debug("stdout:" + str(e.stdout)) + libcalamares.utils.debug("stderr:" + str(e.stderr)) + return (_("Package Manager error"), + _("The package manager could not make changes to the installed system. The command
    {!s}
    returned error code {!s}.") + .format(e.cmd, e.returncode)) + + mode_packages = None + + libcalamares.job.setprogress(1.0) + + return None diff --git a/calamares/src/modules/packages/module.desc b/calamares/src/modules/packages/module.desc new file mode 100644 index 0000000..3e3053b --- /dev/null +++ b/calamares/src/modules/packages/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "packages" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/packages/packages.conf b/calamares/src/modules/packages/packages.conf new file mode 100644 index 0000000..b9777f6 --- /dev/null +++ b/calamares/src/modules/packages/packages.conf @@ -0,0 +1,214 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# The configuration for the package manager starts with the +# *backend* key, which picks one of the backends to use. +# In `main.py` there is a base class `PackageManager`. +# Implementations must subclass that and set a (class-level) +# property *backend* to the name of the backend (e.g. "dummy"). +# That property is used to match against the *backend* key here. +# +# You will have to add such a class for your package manager. +# It is fairly simple Python code. The API is described in the +# abstract methods in class `PackageManager`. Mostly, the only +# trick is to figure out the correct commands to use, and in particular, +# whether additional switches are required or not. Some package managers +# have more installer-friendly defaults than others, e.g., DNF requires +# passing --disablerepo=* -C to allow removing packages without Internet +# connectivity, and it also returns an error exit code if the package did +# not exist to begin with. +--- +# +# Which package manager to use, options are: +# - apk - Alpine Linux package manager +# - apt - APT frontend for DEB and RPM +# - dnf - DNF, the new RPM frontend +# - dnf5 - DNF5, the newer new RPM frontend +# - entropy - Sabayon package manager (is being deprecated) +# - luet - Sabayon package manager (next-gen) +# - packagekit - PackageKit CLI tool +# - pacman - Pacman +# - pamac - Manjaro package manager +# - portage - Gentoo package manager +# - yum - Yum RPM frontend +# - zypp - Zypp RPM frontend +# +# Not actually a package manager, but suitable for testing: +# - dummy - Dummy manager, only logs +# +backend: dummy + +# +# Often package installation needs an internet connection. +# Since you may allow system installation without a connection +# and want to offer OPTIONAL package installation, it's +# possible to have no internet, yet have this packages module +# enabled in settings. +# +# You can skip the whole module when there is no internet +# by setting "skip_if_no_internet" to true. +# +# You can run a package-manager specific update procedure +# before installing packages (for instance, to update the +# list of packages and dependencies); this is done only if there +# is an internet connection. +# +# Set "update_db" to 'true' for refreshing the database on the +# target system. On target installations, which got installed by +# unsquashing, a full system update may be needed. Otherwise +# post-installing additional packages may result in conflicts. +# Therefore set also "update_system" to 'true'. +# +skip_if_no_internet: false +update_db: true +update_system: false + +# pacman specific options +# +# *num_retries* should be a positive integer which specifies the +# number of times the call to pacman will be retried in the event of a +# failure. If it is missing, it will be set to 0. +# +# *disable_download_timeout* is a boolean that, when true, includes +# the flag --disable-download-timeout on calls to pacman. When missing, +# false is assumed. +# +# *needed_only* is a boolean that includes the pacman argument --needed +# when set to true. If missing, false is assumed. +pacman: + num_retries: 0 + disable_download_timeout: false + needed_only: false + +# +# List of maps with package operations such as install or remove. +# Distro developers can provide a list of packages to remove +# from the installed system (for instance packages meant only +# for the live system). +# +# A job implementing a distro specific logic to determine other +# packages that need to be installed or removed can run before +# this one. Distro developers may want to install locale packages +# or remove drivers not needed on the installed system. +# Such a job would populate a list of dictionaries in the global +# storage called "packageOperations" and that list is processed +# after the static list in the job configuration (i.e. the list +# that is in this configuration file). +# +# Allowed package operations are: +# - *install*, *try_install*: will call the package manager to +# install one or more packages. The install target will +# abort the whole installation if package-installation +# fails, while try_install carries on. Packages may be +# listed as (localized) names, or as (localized) package-data. +# See below for the description of the format. +# - *localInstall*: this is used to call the package manager +# to install a package from a path-to-a-package. This is +# useful if you have a static package archive on the install media. +# The *pacman* package manager is the only one to specially support +# this operation (all others treat this the same as *install*). +# - *remove*, *try_remove*: will call the package manager to +# remove one or more packages. The remove target will +# abort the whole installation if package-removal fails, +# while try_remove carries on. Packages may be listed as +# (localized) names. +# One additional key is recognized, to help netinstall out: +# - *source*: ignored, does get logged +# Any other key is ignored, and logged as a warning. +# +# There are two formats for naming packages: as a name or as package-data, +# which is an object notation providing package-name, as well as pre- and +# post-install scripts. +# +# Here are both formats, for installing vi. The first one just names the +# package for vi (using the naming of the installed package manager), while +# the second contains three data-items; the pre-script is run before invoking +# the package manager, and the post-script runs once it is done. +# +# - install +# - vi +# - package: vi +# pre-script: touch /tmp/installing-vi +# post-script: rm -f /tmp/installing-vi +# +# The pre- and post-scripts are optional, but you cannot leave both out +# if you do use the *package* key: using "package: vi" with neither script +# option will trick Calamares into trying to install a package named +# "package: vi", which is unlikely to work. +# +# The pre- and post-scripts are **not** executed by a shell unless you +# explicitly invoke `/bin/sh` in them. The command-lines are passed +# to exec(), which does not understand shell syntax. In other words: +# +# pre-script: ls | wc -l +# +# Will fail, because `|` is passed as a command-line argument to ls, +# as are `wc`, and `-l`. No shell pipeline is set up, and ls is likely +# to complain. Invoke the shell explicitly: +# +# pre-script: /bin/sh -c \"ls | wc -l\" +# +# The above note on shell-expansion applies to versions up-to-and-including +# Calamares 3.2.12, but will change in future. +# +# Any package name may be localized; this is used to install localization +# packages for software based on the selected system locale. By including +# the string `LOCALE` in the package name, the following happens: +# +# - if the system locale is English (any variety), then the package is not +# installed at all, +# - otherwise `$LOCALE` or `${LOCALE}` is replaced by the 'lower-cased' BCP47 +# name of the 'language' part of the selected system locale (not the +# country/region/dialect part), e.g. selecting "nl_BE" will use "nl" +# here. +# +# Take care that just plain `LOCALE` will not be replaced, so `foo-LOCALE` will +# be left unchanged, while `foo-$LOCALE` will be changed. However, `foo-LOCALE` +# **will** be removed from the list of packages (i.e. not installed), if +# English is selected. If a non-English locale is selected, then `foo-LOCALE` +# will be installed, unchanged (no language-name-substitution occurs). +# +# The following installs localizations for vi, if they are relevant; if +# there is no localization, installation continues normally. +# +# - install +# - vi-$LOCALE +# - package: vi-${LOCALE} +# pre-script: touch /tmp/installing-vi +# post-script: rm -f /tmp/installing-vi +# +# When installing packages, Calamares will invoke the package manager +# with a list of package names if it can; package-data prevents this because +# of the scripts that need to run. In other words, this: +# +# - install: +# - vi +# - binutils +# - package: wget +# pre-script: touch /tmp/installing-wget +# +# This will invoke the package manager three times, once for each package, +# because not all of them are simple package names. You can speed up the +# process if you have only a few pre-scripts, by using multiple install targets: +# +# - install: +# - vi +# - binutils +# - install: +# - package: wget +# pre-script: touch /tmp/installing-wget +# +# This will call the package manager once with the package-names "vi" and +# "binutils", and then a second time for "wget". When installing large numbers +# of packages, this can lead to a considerable time savings. +# +operations: + - install: + - vi + - vi-${LOCALE} + - wget + - binutils + - remove: + - vi + - wget + - binutils diff --git a/calamares/src/modules/packages/packages.schema.yaml b/calamares/src/modules/packages/packages.schema.yaml new file mode 100644 index 0000000..3e3b516 --- /dev/null +++ b/calamares/src/modules/packages/packages.schema.yaml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/packages +additionalProperties: false +type: object +properties: + backend: + type: string + enum: + - apk + - apt + - dnf + - dnf5 + - entropy + - luet + - packagekit + - pacman + - pamac + - portage + - yum + - zypp + - dummy + + update_db: { type: boolean, default: true } + update_system: { type: boolean, default: false } + skip_if_no_internet: { type: boolean, default: false } + + pacman: + additionalProperties: false + type: object + properties: + num_retries: { type: integer, default: 0 } + disable_download_timeout: { type: boolean, default: false } + needed_only: { type: boolean, default: false } + + operations: + type: array + items: + additionalProperties: false + type: object + properties: + # TODO: these are either-string-or-struct items, + # need their own little schema. + install: { type: array } + remove: { type: array } + try_install: { type: array } + try_remove: { type: array } + localInstall: { type: array } + source: { type: string } + + +required: [ backend ] diff --git a/calamares/src/modules/packages/tests/1.global b/calamares/src/modules/packages/tests/1.global new file mode 100644 index 0000000..ee06ccf --- /dev/null +++ b/calamares/src/modules/packages/tests/1.global @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +rootMountPoint: /tmp diff --git a/calamares/src/modules/packages/tests/2.job b/calamares/src/modules/packages/tests/2.job new file mode 100644 index 0000000..ba205ed --- /dev/null +++ b/calamares/src/modules/packages/tests/2.job @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +backend: dummy +operations: + - install: + - pre-script: touch /tmp/foo + package: vi + post-script: rm /tmp/foo + - wget + - binutils + - remove: + - vi + - wget diff --git a/calamares/src/modules/packages/tests/CMakeTests.txt b/calamares/src/modules/packages/tests/CMakeTests.txt new file mode 100644 index 0000000..66da86b --- /dev/null +++ b/calamares/src/modules/packages/tests/CMakeTests.txt @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# We have tests to load (some) of the package-managers specifically, to +# test their configuration code and implementation. Those tests conventionally +# live in Python files here in the tests/ directory. Add them. + +# Pacman (Arch) tests +set(_pm pacman) +add_test( + NAME configure-packages-${_pm} + COMMAND env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-pm-${_pm}.py + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) +add_test( + NAME configure-packages-${_pm}-ops-1 + COMMAND + env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-pm-${_pm}.py ${CMAKE_CURRENT_LIST_DIR}/pm-pacman-1.yaml + 4 1 1 + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) +add_test( + NAME configure-packages-${_pm}-ops-2 + COMMAND + env PYTHONPATH=.: python3 ${CMAKE_CURRENT_LIST_DIR}/test-pm-${_pm}.py ${CMAKE_CURRENT_LIST_DIR}/pm-pacman-2.yaml + 3 0 0 + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} +) + +if(BUILD_TESTING AND BUILD_SCHEMA_TESTING AND Python_Interpreter_FOUND) + set(_module packages) + set(_schema_file "${CMAKE_CURRENT_SOURCE_DIR}/${_module}/${_module}.schema.yaml") + message(STATUS "Schema ${_schema_file}") + foreach(_cf pm-pacman-1.yaml pm-pacman-2.yaml) + set(_conf_file "${CMAKE_CURRENT_SOURCE_DIR}/${_module}/tests/${_cf}") + if(EXISTS "${_schema_file}" AND EXISTS "${_conf_file}") + add_test( + NAME validate-packages-${_cf} + COMMAND + ${Python_EXECUTABLE} "${CMAKE_SOURCE_DIR}/ci/configvalidator.py" "${_schema_file}" "${_conf_file}" + ) + else() + message(FATAL_ERROR "Missing ${_conf_file}") + endif() + endforeach() +endif() diff --git a/calamares/src/modules/packages/tests/pm-pacman-1.yaml b/calamares/src/modules/packages/tests/pm-pacman-1.yaml new file mode 100644 index 0000000..aeb5b86 --- /dev/null +++ b/calamares/src/modules/packages/tests/pm-pacman-1.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +backend: pacman +operations: [] + +pacman: + num_retries: 4 + disable_download_timeout: true + needed_only: true + diff --git a/calamares/src/modules/packages/tests/pm-pacman-2.yaml b/calamares/src/modules/packages/tests/pm-pacman-2.yaml new file mode 100644 index 0000000..8b0bda3 --- /dev/null +++ b/calamares/src/modules/packages/tests/pm-pacman-2.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +backend: pacman +operations: [] + +# Leave some things unspecified +pacman: + num_retries: 3 + diff --git a/calamares/src/modules/packages/tests/test-pm-pacman.py b/calamares/src/modules/packages/tests/test-pm-pacman.py new file mode 100644 index 0000000..ee814b6 --- /dev/null +++ b/calamares/src/modules/packages/tests/test-pm-pacman.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Calamares Boilerplate +import libcalamares +libcalamares.globalstorage = libcalamares.GlobalStorage(None) +libcalamares.globalstorage.insert("testing", True) + +# Module prep-work +from src.modules.packages import main + +# .. we don't have a job in this test, so fake one +class Job(object): + def __init__(self, filename): + self.configuration = libcalamares.utils.load_yaml(filename) if filename is not None else dict() + +import sys +if len(sys.argv) > 4: + filename = sys.argv[1] + retry = int(sys.argv[2]) + timeout = bool(int(sys.argv[3])) + needed = bool(int(sys.argv[4])) +else: + filename = None + retry = 0 + timeout = False + needed = False + +libcalamares.utils.warning("Expecting {!s} retry={!s} timeout={!s} needed={!s}".format(filename, retry, timeout, needed)) + +# Specific PM test +libcalamares.job = Job(filename) +p = main.PMPacman() +assert p.pacman_num_retries == retry, "{!r} vs {!r}".format(p.pacman_num_retries, retry) +assert p.pacman_disable_timeout == timeout, "{!r} vs {!r}".format(p.pacman_disable_timeout, timeout) +assert p.pacman_needed_only == needed, "{!r} vs {!r}".format(p.pacman_needed_only, needed) diff --git a/calamares/src/modules/partition/CMakeLists.txt b/calamares/src/modules/partition/CMakeLists.txt new file mode 100644 index 0000000..b5185bc --- /dev/null +++ b/calamares/src/modules/partition/CMakeLists.txt @@ -0,0 +1,124 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# When debugging the partitioning widget, or experimenting, you may +# want to allow unsafe partitioning choices (e.g. doing things to the +# current disk). Set DEBUG_PARTITION_UNSAFE to allow that (it turns off +# some filtering of devices). If you **do** allow unsafe partitioning, +# it will error out at runtime unless you **also** switch **off** +# DEBUG_PARTITION_BAIL_OUT, at which point you are welcome to shoot +# yourself in the foot. +# +# Independently, DEBUG_PARTITION_SKIP will not do the actual partitioning +# through KPMCore, but it **will** save the global storage setup as if +# it has done the partitioning. This is going to confuse subsequent +# modules since the partitions on disk won't match GS, but it can be +# useful for debugging simulated installations that don't need to +# mount the target filesystems. +option(DEBUG_PARTITION_UNSAFE "Allow unsafe partitioning choices." OFF) +option(DEBUG_PARTITION_BAIL_OUT "Unsafe partitioning will error out on exec." ON) +option(DEBUG_PARTITION_SKIP "Don't actually do any partitioning." OFF) + +# This is very chatty, useful mostly if you don't know what KPMCore offers. +option(DEBUG_FILESYSTEMS "Log all available Filesystems from KPMCore." OFF) + +include_directories(${CMAKE_SOURCE_DIR}) # For 3rdparty + +set(_partition_defs) +if(DEBUG_PARTITION_UNSAFE) + if(DEBUG_PARTITION_BAIL_OUT) + list(APPEND _partition_defs DEBUG_PARTITION_BAIL_OUT) + endif() + list(APPEND _partition_defs DEBUG_PARTITION_UNSAFE) +endif() +if(DEBUG_FILESYSTEMS) + list(APPEND _partition_defs DEBUG_FILESYSTEMS) +endif() +if(DEBUG_PARTITION_SKIP) + list(APPEND _partition_defs DEBUG_PARTITION_SKIP) +endif() + +include(KPMcoreHelper) + +if(KPMcore_FOUND) + include_directories(${PROJECT_BINARY_DIR}/src/libcalamaresui) + + add_subdirectory(tests) + + calamares_add_plugin(partition + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + PartitionViewStep.cpp + + core/BootLoaderModel.cpp + core/ColorUtils.cpp + core/DeviceList.cpp + core/DeviceModel.cpp + core/KPMHelpers.cpp + core/DirFSRestrictLayout.cpp + core/OsproberEntry.cpp + core/PartitionActions.cpp + core/PartitionCoreModule.cpp + core/PartitionInfo.cpp + core/PartitionLayout.cpp + core/PartitionModel.cpp + core/PartUtils.cpp + gui/BootInfoWidget.cpp + gui/ChoicePage.cpp + gui/CreatePartitionDialog.cpp + gui/CreateVolumeGroupDialog.cpp + gui/DeviceInfoWidget.cpp + gui/EditExistingPartitionDialog.cpp + gui/EncryptWidget.cpp + gui/ListPhysicalVolumeWidgetItem.cpp + gui/PartitionPage.cpp + gui/PartitionBarsView.cpp + gui/PartitionDialogHelpers.cpp + gui/PartitionLabelsView.cpp + gui/PartitionSizeController.cpp + gui/PartitionSplitterWidget.cpp + gui/ResizeVolumeGroupDialog.cpp + gui/ScanningDialog.cpp + gui/VolumeGroupBaseDialog.cpp + jobs/AutoMountManagementJob.cpp + jobs/ChangeFilesystemLabelJob.cpp + jobs/ClearMountsJob.cpp + jobs/ClearTempMountsJob.cpp + jobs/CreatePartitionJob.cpp + jobs/CreatePartitionTableJob.cpp + jobs/CreateVolumeGroupJob.cpp + jobs/DeactivateVolumeGroupJob.cpp + jobs/DeletePartitionJob.cpp + jobs/FillGlobalStorageJob.cpp + jobs/FormatPartitionJob.cpp + jobs/PartitionJob.cpp + jobs/RemoveVolumeGroupJob.cpp + jobs/ResizePartitionJob.cpp + jobs/ResizeVolumeGroupJob.cpp + jobs/SetPartitionFlagsJob.cpp + UI + gui/ChoicePage.ui + gui/CreatePartitionDialog.ui + gui/CreatePartitionTableDialog.ui + gui/EditExistingPartitionDialog.ui + gui/EncryptWidget.ui + gui/PartitionPage.ui + gui/VolumeGroupBaseDialog.ui + LINK_PRIVATE_LIBRARIES + calamares::kpmcore + ${kfname}::CoreAddons + COMPILE_DEFINITIONS ${_partition_defs} + SHARED_LIB + ) +else() + if(NOT KPMcore_FOUND) + calamares_skip_module( "partition (missing suitable KPMcore)" ) + else() + calamares_skip_module( "partition (missing dependencies for KPMcore)" ) + endif() +endif() diff --git a/calamares/src/modules/partition/Config.cpp b/calamares/src/modules/partition/Config.cpp new file mode 100644 index 0000000..b2f8878 --- /dev/null +++ b/calamares/src/modules/partition/Config.cpp @@ -0,0 +1,475 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "core/PartUtils.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "partition/PartitionSize.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +Config::Config( QObject* parent ) + : QObject( parent ) +{ +} + +const NamedEnumTable< Config::InstallChoice >& +Config::installChoiceNames() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< InstallChoice > names { + { QStringLiteral( "none" ), InstallChoice::NoChoice }, + { QStringLiteral( "nochoice" ), InstallChoice::NoChoice }, + { QStringLiteral( "alongside" ), InstallChoice::Alongside }, + { QStringLiteral( "erase" ), InstallChoice::Erase }, + { QStringLiteral( "replace" ), InstallChoice::Replace }, + { QStringLiteral( "manual" ), InstallChoice::Manual }, + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +const NamedEnumTable< Config::SwapChoice >& +Config::swapChoiceNames() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< SwapChoice > names { + { QStringLiteral( "none" ), SwapChoice::NoSwap }, + { QStringLiteral( "small" ), SwapChoice::SmallSwap }, + { QStringLiteral( "suspend" ), SwapChoice::FullSwap }, + { QStringLiteral( "reuse" ), SwapChoice::ReuseSwap }, + { QStringLiteral( "file" ), SwapChoice::SwapFile }, + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +const NamedEnumTable< Config::LuksGeneration >& +Config::luksGenerationNames() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< LuksGeneration > names { + { QStringLiteral( "luks1" ), LuksGeneration::Luks1 }, + { QStringLiteral( "luks" ), LuksGeneration::Luks1 }, + { QStringLiteral( "luks2" ), LuksGeneration::Luks2 }, + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +Config::SwapChoice +pickOne( const Config::SwapChoiceSet& s ) +{ + if ( s.count() == 0 ) + { + return Config::SwapChoice::NoSwap; + } + if ( s.count() == 1 ) + { + return *( s.begin() ); + } + if ( s.contains( Config::SwapChoice::NoSwap ) ) + { + return Config::SwapChoice::NoSwap; + } + // Here, count > 1 but NoSwap is not a member. + return *( s.begin() ); +} + +static Config::SwapChoiceSet +getSwapChoices( const QVariantMap& configurationMap ) +{ + // SWAP SETTINGS + // + // This is a bit convoluted because there's legacy settings to handle as well + // as the new-style list of choices, with mapping back-and-forth. + if ( configurationMap.contains( "userSwapChoices" ) + && ( configurationMap.contains( "ensureSuspendToDisk" ) || configurationMap.contains( "neverCreateSwap" ) ) ) + { + cError() << "Partition-module configuration mixes old- and new-style swap settings."; + } + + if ( configurationMap.contains( "ensureSuspendToDisk" ) ) + { + cWarning() << "Partition-module setting *ensureSuspendToDisk* is deprecated."; + } + bool ensureSuspendToDisk = Calamares::getBool( configurationMap, "ensureSuspendToDisk", true ); + + if ( configurationMap.contains( "neverCreateSwap" ) ) + { + cWarning() << "Partition-module setting *neverCreateSwap* is deprecated."; + } + bool neverCreateSwap = Calamares::getBool( configurationMap, "neverCreateSwap", false ); + + Config::SwapChoiceSet choices; // Available swap choices + if ( configurationMap.contains( "userSwapChoices" ) ) + { + // We've already warned about overlapping settings with the + // legacy *ensureSuspendToDisk* and *neverCreateSwap*. + QStringList l = configurationMap[ "userSwapChoices" ].toStringList(); + + for ( const auto& item : l ) + { + bool ok = false; + auto v = Config::swapChoiceNames().find( item, ok ); + if ( ok ) + { + choices.insert( v ); + } + } + + if ( choices.isEmpty() ) + { + cWarning() << "Partition-module configuration for *userSwapChoices* is empty:" << l; + choices.insert( Config::SwapChoice::FullSwap ); + } + + // suspend if it's one of the possible choices; suppress swap only if it's + // the **only** choice available. + ensureSuspendToDisk = choices.contains( Config::SwapChoice::FullSwap ); + neverCreateSwap = ( choices.count() == 1 ) && choices.contains( Config::SwapChoice::NoSwap ); + } + else + { + // Convert the legacy settings into a single setting for now. + if ( neverCreateSwap ) + { + choices.insert( Config::SwapChoice::NoSwap ); + } + else if ( ensureSuspendToDisk ) + { + choices.insert( Config::SwapChoice::FullSwap ); + } + else + { + choices.insert( Config::SwapChoice::SmallSwap ); + } + } + + // Not all are supported right now // FIXME + static const char unsupportedSetting[] = "Partition-module does not support *userSwapChoices* setting"; + +#define COMPLAIN_UNSUPPORTED( x ) \ + if ( choices.contains( x ) ) \ + { \ + bool bogus = false; \ + cWarning() << unsupportedSetting << Config::swapChoiceNames().find( x, bogus ); \ + choices.remove( x ); \ + } + + COMPLAIN_UNSUPPORTED( Config::SwapChoice::ReuseSwap ) +#undef COMPLAIN_UNSUPPORTED + + return choices; +} + +void +updateGlobalStorage( Config::InstallChoice installChoice, Config::SwapChoice swapChoice ) +{ + auto* gs = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + if ( gs ) + { + QVariantMap m; + m.insert( "install", Config::installChoiceNames().find( installChoice ) ); + m.insert( "swap", Config::swapChoiceNames().find( swapChoice ) ); + gs->insert( "partitionChoices", m ); + } +} + +void +Config::setInstallChoice( int c ) +{ + if ( ( c < InstallChoice::NoChoice ) || ( c > InstallChoice::Manual ) ) + { + cWarning() << "Invalid install choice (int)" << c; + c = InstallChoice::NoChoice; + } + setInstallChoice( static_cast< InstallChoice >( c ) ); +} + +void +Config::setInstallChoice( InstallChoice c ) +{ + if ( c != m_installChoice ) + { + m_installChoice = c; + Q_EMIT installChoiceChanged( c ); + ::updateGlobalStorage( c, m_swapChoice ); + } +} + +void +Config::setSwapChoice( int c ) +{ + if ( ( c < SwapChoice::NoSwap ) || ( c > SwapChoice::SwapFile ) ) + { + cWarning() << "Invalid swap choice (int)" << c; + c = SwapChoice::NoSwap; + } + setSwapChoice( static_cast< SwapChoice >( c ) ); +} + +void +Config::setSwapChoice( Config::SwapChoice c ) +{ + if ( c != m_swapChoice ) + { + m_swapChoice = c; + Q_EMIT swapChoiceChanged( c ); + ::updateGlobalStorage( m_installChoice, c ); + } +} + +void +Config::setEraseFsTypeChoice( const QString& choice ) +{ + const QString canonicalChoice = PartUtils::canonicalFilesystemName( choice, nullptr ); + if ( canonicalChoice != m_eraseFsTypeChoice ) + { + m_eraseFsTypeChoice = canonicalChoice; + Q_EMIT eraseModeFilesystemChanged( canonicalChoice ); + } +} + +void +Config::setReplaceFilesystemChoice( const QString& filesystemName ) +{ + const QString canonicalChoice = PartUtils::canonicalFilesystemName( filesystemName, nullptr ); + if ( canonicalChoice != m_replaceFileSystemChoice ) + { + m_replaceFileSystemChoice = canonicalChoice; + Q_EMIT replaceModeFilesystemChanged( canonicalChoice ); + } +} + +bool +Config::acceptPartitionTableType( PartitionTable::TableType tableType ) const +{ + return m_requiredPartitionTableType.empty() + || m_requiredPartitionTableType.contains( PartitionTable::tableTypeToName( tableType ) ); +} + +static void +fillGSConfigurationEFI( Calamares::GlobalStorage* gs, const QVariantMap& configurationMap ) +{ + // Set up firmwareType global storage entry. This is used, e.g. by the bootloader module. + QString firmwareType( PartUtils::isEfiSystem() ? QStringLiteral( "efi" ) : QStringLiteral( "bios" ) ); + gs->insert( "firmwareType", firmwareType ); + + bool ok = false; + auto efiConfiguration = Calamares::getSubMap( configurationMap, "efi", ok ); + + // Mount Point + { + const auto efiSystemPartition = Calamares::getString( + efiConfiguration, + "mountPoint", + Calamares::getString( configurationMap, "efiSystemPartition", QStringLiteral( "/boot/efi" ) ) ); + // This specific GS key is also used by bootloader and grubcfg modules, + // as well as partition module internalls. + gs->insert( "efiSystemPartition", efiSystemPartition ); + } + + // Sizes + { + const auto efiRecommendedSize = Calamares::getString( + efiConfiguration, "recommendedSize", Calamares::getString( configurationMap, "efiSystemPartitionSize" ) ); + if ( !efiRecommendedSize.isEmpty() ) + { + Calamares::Partition::PartitionSize part_size = Calamares::Partition::PartitionSize( efiRecommendedSize ); + if ( part_size.isValid() ) + { + gs->insert( PartUtils::efiFilesystemRecommendedSizeGSKey(), part_size.toBytes() ); + + // Assign long long int to long unsigned int to prevent compilation warning, + // checks for loss-of-precision in the conversion. + auto byte_part_size = part_size.toBytes(); + if ( byte_part_size != PartUtils::efiFilesystemRecommendedSize() ) + { + cWarning() << "EFI partition size" << efiRecommendedSize << "has been adjusted to" + << PartUtils::efiFilesystemRecommendedSize() << "bytes"; + } + } + else + { + cWarning() << "EFI partition size" << efiRecommendedSize << "is invalid, ignored"; + } + } + + const auto efiMinimumSize = Calamares::getString( efiConfiguration, "minimumSize" ); + if ( !efiMinimumSize.isEmpty() ) + { + Calamares::Partition::PartitionSize part_size = Calamares::Partition::PartitionSize( efiMinimumSize ); + if ( part_size.isValid() ) + { + if ( part_size.toBytes() > PartUtils::efiFilesystemRecommendedSize() ) + { + cWarning() << "EFI minimum size" << efiMinimumSize << "is larger than the recommended size" + << efiRecommendedSize << ", ignored."; + } + else + { + gs->insert( PartUtils::efiFilesystemMinimumSizeGSKey(), part_size.toBytes() ); + } + } + } + } + + // Name (label) of partition + { + const auto efiLabel = Calamares::getString( + efiConfiguration, "label", Calamares::getString( configurationMap, "efiSystemPartitionName" ) ); + + if ( !efiLabel.isEmpty() ) + { + gs->insert( "efiSystemPartitionName", efiLabel ); + } + } +} + +void +Config::fillConfigurationFSTypes( const QVariantMap& configurationMap ) +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + // The defaultFileSystemType setting needs a bit more processing, + // as we want to cover various cases (such as different cases) + QString fsName = Calamares::getString( configurationMap, "defaultFileSystemType" ); + QString fsRealName; + FileSystem::Type fsType = FileSystem::Type::Unknown; + if ( fsName.isEmpty() ) + { + cWarning() << "Partition-module setting *defaultFileSystemType* is missing, will use ext4"; + fsRealName = PartUtils::canonicalFilesystemName( QStringLiteral( "ext4" ), &fsType ); + } + else + { + fsRealName = PartUtils::canonicalFilesystemName( fsName, &fsType ); + if ( fsType == FileSystem::Type::Unknown ) + { + cWarning() << "Partition-module setting *defaultFileSystemType* is bad (" << fsName + << ") using ext4 instead"; + fsRealName = PartUtils::canonicalFilesystemName( QStringLiteral( "ext4" ), &fsType ); + } + else if ( fsRealName != fsName ) + { + cWarning() << "Partition-module setting *defaultFileSystemType* changed to" << fsRealName; + } + } + Q_ASSERT( fsType != FileSystem::Type::Unknown ); + m_defaultFsType = fsType; + gs->insert( "defaultFileSystemType", fsRealName ); + + // TODO: canonicalize the names? How is translation supposed to work? + m_eraseFsTypes = Calamares::getStringList( configurationMap, "availableFileSystemTypes" ); + if ( !m_eraseFsTypes.contains( fsRealName ) ) + { + if ( !m_eraseFsTypes.isEmpty() ) + { + // Explicitly set, and doesn't include the default + cWarning() << "Partition-module *availableFileSystemTypes* does not contain the default" << fsRealName; + m_eraseFsTypes.prepend( fsRealName ); + } + else + { + // Not explicitly set, so it's empty; don't complain + m_eraseFsTypes = QStringList { fsRealName }; + } + } + + // Set LUKS file system based on luksGeneration provided, defaults to 'luks'. + bool nameFound = false; + Config::LuksGeneration luksGeneration + = luksGenerationNames().find( Calamares::getString( configurationMap, "luksGeneration" ), nameFound ); + if ( !nameFound ) + { + cWarning() << "Partition-module setting *luksGeneration* not found or invalid. Defaulting to luks1."; + luksGeneration = Config::LuksGeneration::Luks1; + } + m_luksFileSystemType = luksGeneration; + gs->insert( "luksFileSystemType", luksGenerationNames().find( luksGeneration ) ); + + Q_ASSERT( !m_eraseFsTypes.isEmpty() ); + Q_ASSERT( m_eraseFsTypes.contains( fsRealName ) ); + m_eraseFsTypeChoice = fsRealName; + m_replaceFileSystemChoice = fsRealName; + Q_EMIT eraseModeFilesystemChanged( m_eraseFsTypeChoice ); + Q_EMIT replaceModeFilesystemChanged( m_replaceFileSystemChoice ); +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + // Settings that overlap with the Welcome module + m_requiredStorageGiB = Calamares::getDouble( configurationMap, "requiredStorage", -1.0 ); + m_swapChoices = getSwapChoices( configurationMap ); + + bool nameFound = false; // In the name table (ignored, falls back to first entry in table) + m_initialInstallChoice + = installChoiceNames().find( Calamares::getString( configurationMap, "initialPartitioningChoice" ), nameFound ); + setInstallChoice( m_initialInstallChoice ); + + m_initialSwapChoice + = swapChoiceNames().find( Calamares::getString( configurationMap, "initialSwapChoice" ), nameFound ); + if ( !m_swapChoices.contains( m_initialSwapChoice ) ) + { + cWarning() << "Configuration for *initialSwapChoice* is not one of the *userSwapChoices*"; + if ( nameFound ) + { + cWarning() << Logger::SubEntry << "Choice" << swapChoiceNames().find( m_initialSwapChoice ) << "added."; + m_swapChoices.insert( m_initialSwapChoice ); + } + m_initialSwapChoice = pickOne( m_swapChoices ); + } + setSwapChoice( m_initialSwapChoice ); + + m_allowZfsEncryption = Calamares::getBool( configurationMap, "allowZfsEncryption", true ); + + m_allowManualPartitioning = Calamares::getBool( configurationMap, "allowManualPartitioning", true ); + m_preCheckEncryption = Calamares::getBool( configurationMap, "preCheckEncryption", false ); + m_showNotEncryptedBootMessage = Calamares::getBool( configurationMap, "showNotEncryptedBootMessage", true ); + m_requiredPartitionTableType = Calamares::getStringList( configurationMap, "requiredPartitionTableType" ); + + { + bool bogus = true; + const auto lvmConfiguration = Calamares::getSubMap( configurationMap, "lvm", bogus ); + m_isLVMEnabled = Calamares::getBool( lvmConfiguration, "enable", true ); + } + + m_essentialMounts= Calamares::getStringList( configurationMap, "essentialMounts" ); + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + gs->insert( "armInstall", Calamares::getBool( configurationMap, "armInstall", false ) ); + fillGSConfigurationEFI( gs, configurationMap ); + fillConfigurationFSTypes( configurationMap ); +} + +void +Config::fillGSSecondaryConfiguration() const +{ + // If there's no setting (e.g. from the welcome page) for required storage + // then use ours, if it was set. + auto* gs = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + if ( m_requiredStorageGiB >= 0.0 && gs && !gs->contains( "requiredStorageGiB" ) ) + { + gs->insert( "requiredStorageGiB", m_requiredStorageGiB ); + } +} diff --git a/calamares/src/modules/partition/Config.h b/calamares/src/modules/partition/Config.h new file mode 100644 index 0000000..d2dfec6 --- /dev/null +++ b/calamares/src/modules/partition/Config.h @@ -0,0 +1,238 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITION_CONFIG_H +#define PARTITION_CONFIG_H + +#include "utils/NamedEnum.h" + +#include +#include + +#include +#include + +class Config : public QObject +{ + Q_OBJECT + ///@brief The installation choice (Erase, Alongside, ...) + Q_PROPERTY( InstallChoice installChoice READ installChoice WRITE setInstallChoice NOTIFY installChoiceChanged ) + + ///@brief The swap choice (None, Small, Hibernate, ...) which only makes sense when Erase is chosen + Q_PROPERTY( SwapChoice swapChoice READ swapChoice WRITE setSwapChoice NOTIFY swapChoiceChanged ) + + ///@brief Name of the FS that will be used when erasing type disk (e.g. "default filesystem") + Q_PROPERTY( + QString eraseModeFilesystem READ eraseFsType WRITE setEraseFsTypeChoice NOTIFY eraseModeFilesystemChanged ) + + Q_PROPERTY( QString replaceModeFilesystem READ replaceModeFilesystem WRITE setReplaceFilesystemChoice NOTIFY + replaceModeFilesystemChanged ) + + Q_PROPERTY( bool allowManualPartitioning READ allowManualPartitioning CONSTANT FINAL ) + Q_PROPERTY( bool preCheckEncryption READ preCheckEncryption CONSTANT FINAL ) + Q_PROPERTY( bool showNotEncryptedBootMessage READ showNotEncryptedBootMessage CONSTANT FINAL ) + + Q_PROPERTY( bool lvmEnabled READ isLVMEnabled CONSTANT FINAL ) + + Q_PROPERTY( QStringList essentialMounts READ essentialMounts CONSTANT FINAL ) + +public: + Config( QObject* parent ); + ~Config() override = default; + + enum InstallChoice + { + NoChoice, + Alongside, + Erase, + Replace, + Manual + }; + Q_ENUM( InstallChoice ) + static const NamedEnumTable< InstallChoice >& installChoiceNames(); + + /** @brief Choice of swap (size and type) */ + enum SwapChoice + { + NoSwap, // don't create any swap, don't use any + ReuseSwap, // don't create, but do use existing + SmallSwap, // up to 8GiB of swap + FullSwap, // ensureSuspendToDisk -- at least RAM size + SwapFile // use a file (if supported) + }; + Q_ENUM( SwapChoice ) + static const NamedEnumTable< SwapChoice >& swapChoiceNames(); + using SwapChoiceSet = QSet< SwapChoice >; + + using EraseFsTypesSet = QStringList; + + /** @brief Choice of LUKS disk encryption generation */ + enum class LuksGeneration + { + Luks1, // First generation of LUKS + Luks2, // Second generation of LUKS, default since cryptsetup >= 2.1.0 + }; + Q_ENUM( LuksGeneration ) + static const NamedEnumTable< LuksGeneration >& luksGenerationNames(); + + void setConfigurationMap( const QVariantMap& ); + /** @brief Set GS values where other modules configuration has priority + * + * Some "required" values are duplicated between modules; if some + * othe module hasn't already set the GS value, take a value from + * the partitioning configuration. + * + * Applicable GS keys: + * - requiredStorageGiB + */ + void fillGSSecondaryConfiguration() const; + + /** @brief What kind of installation (partitioning) is requested **initially**? + * + * @return the partitioning choice (may be @c NoChoice) + */ + InstallChoice initialInstallChoice() const { return m_initialInstallChoice; } + + /** @brief What kind of installation (partition) is requested **now**? + * + * This changes depending on what the user selects (unlike the initial choice, + * which is fixed by the configuration). + * + * @return the partitioning choice (may be @c NoChoice) + */ + InstallChoice installChoice() const { return m_installChoice; } + + /** @brief The set of swap choices enabled for this install + * + * Not all swap choices are supported by each distro, so they + * can choose to enable or disable them. This method + * returns a set (hopefully non-empty) of configured swap choices. + */ + SwapChoiceSet swapChoices() const { return m_swapChoices; } + + /** @brief What kind of swap selection is requested **initially**? + * + * @return The swap choice (may be @c NoSwap ) + */ + SwapChoice initialSwapChoice() const { return m_initialSwapChoice; } + + /** @brief What kind of swap selection is requested **now**? + * + * A choice of swap only makes sense when install choice Erase is made. + * + * @return The swap choice (may be @c NoSwap). + */ + SwapChoice swapChoice() const { return m_swapChoice; } + + /** @brief Get the list of configured FS types to use with *erase* mode + * + * This list is not empty. + */ + EraseFsTypesSet eraseFsTypes() const { return m_eraseFsTypes; } + + /** @brief Currently-selected FS type for *erase* mode + */ + QString eraseFsType() const { return m_eraseFsTypeChoice; } + + /// @brief Currently-selected FS type for *replace* mode + QString replaceModeFilesystem() const { return m_replaceFileSystemChoice; } + + /** @brief Configured default FS type (for other modes than erase) + * + * This is not "Unknown" or "Unformatted" + */ + FileSystem::Type defaultFsType() const { return m_defaultFsType; } + + /// @brief Is manual partitioning allowed (not explicitly disabled in the config file)? + bool allowManualPartitioning() const { return m_allowManualPartitioning; } + + /** @brief Pre-check encryption checkbox. + * + * This is meaningful only if enableLuksAutomatedPartitioning is @c true. + * Default value is @c false + */ + bool preCheckEncryption() const { return m_preCheckEncryption; } + + /// @brief Show "Boot partition not encrypted" warning (not explicitly disabled in the config file)? + bool showNotEncryptedBootMessage() const { return m_showNotEncryptedBootMessage; } + + /** @brief Will @p tableType be ok? + * + * If no required types are specified, it's ok, otherwise the + * type must be named in the list of required types. + */ + bool acceptPartitionTableType( PartitionTable::TableType tableType ) const; + /// @brief Returns list of acceptable types. May be empty. + QStringList partitionTableTypes() const { return m_requiredPartitionTableType; } + + /** @brief The configured LUKS generation (1 or 2) + */ + LuksGeneration luksFileSystemType() const { return m_luksFileSystemType; } + + /// @brief If zfs encryption should be allowed + bool allowZfsEncryption() const { return m_allowZfsEncryption; } + + bool isLVMEnabled() const { return m_isLVMEnabled; } + + /** @brief A list of names that can follow /dev/mapper/ that must not be closed + * + * These names (if any) are skipped by the ClearMountsJob. + * The names may contain a trailing '*' which acts as a wildcard. + * In any other position, '*' is interpreted literally. + */ + QStringList essentialMounts() const { return m_essentialMounts; } + +public Q_SLOTS: + void setInstallChoice( int ); ///< Translates a button ID or so to InstallChoice + void setInstallChoice( InstallChoice ); + void setSwapChoice( int ); ///< Translates a button ID or so to SwapChoice + void setSwapChoice( SwapChoice ); + void setEraseFsTypeChoice( const QString& filesystemName ); ///< See property eraseModeFilesystem + void setReplaceFilesystemChoice( const QString& filesystemName ); + +Q_SIGNALS: + void installChoiceChanged( InstallChoice ); + void swapChoiceChanged( SwapChoice ); + void eraseModeFilesystemChanged( const QString& ); + void replaceModeFilesystemChanged( const QString& ); + +private: + /** @brief Handle FS-type configuration, for erase and default */ + void fillConfigurationFSTypes( const QVariantMap& configurationMap ); + EraseFsTypesSet m_eraseFsTypes; + QString m_eraseFsTypeChoice; + QString m_replaceFileSystemChoice; + FileSystem::Type m_defaultFsType; + + SwapChoiceSet m_swapChoices; + SwapChoice m_initialSwapChoice = NoSwap; + SwapChoice m_swapChoice = NoSwap; + LuksGeneration m_luksFileSystemType = LuksGeneration::Luks1; + InstallChoice m_initialInstallChoice = NoChoice; + InstallChoice m_installChoice = NoChoice; + qreal m_requiredStorageGiB = 0.0; // May duplicate setting in the welcome module + QStringList m_requiredPartitionTableType; + bool m_allowZfsEncryption = true; + bool m_allowManualPartitioning = true; + bool m_preCheckEncryption = false; + bool m_showNotEncryptedBootMessage = true; + bool m_isLVMEnabled = true; + QStringList m_essentialMounts; +}; + +/** @brief Given a set of swap choices, return a sensible value from it. + * + * "Sensible" here means: if there is one value, use it; otherwise, use + * NoSwap if there are no choices, or if NoSwap is one of the choices, in the set. + * If that's not possible, any value from the set. + */ +Config::SwapChoice pickOne( const Config::SwapChoiceSet& s ); + + +#endif diff --git a/calamares/src/modules/partition/PartitionViewStep.cpp b/calamares/src/modules/partition/PartitionViewStep.cpp new file mode 100644 index 0000000..f19d906 --- /dev/null +++ b/calamares/src/modules/partition/PartitionViewStep.cpp @@ -0,0 +1,979 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 2020, Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2020 Anke Boersma +#include + +#include +#include +#include +#include + +PartitionViewStep::PartitionViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_config( new Config( this ) ) + , m_core( nullptr ) + , m_widget( new QStackedWidget() ) + , m_choicePage( nullptr ) + , m_manualPartitionPage( nullptr ) +{ + m_widget->setContentsMargins( 0, 0, 0, 0 ); + + m_waitingWidget = new WaitingWidget( QString() ); + m_widget->addWidget( m_waitingWidget ); + CALAMARES_RETRANSLATE( + if ( m_waitingWidget ) { m_waitingWidget->setText( tr( "Gathering system information…", "@status" ) ); } ); + + m_core = new PartitionCoreModule( this ); // Unusable before init is complete! + // We're not done loading, but we need the configuration map first. +} + +PartitionViewStep::FSConflictEntry::FSConflictEntry() {} + +PartitionViewStep::FSConflictEntry::FSConflictEntry( const QString& conflictingPathArg, + const QString& conflictingFilesystemArg, + const QString& conflictedPathArg, + QStringList allowableFilesystemsArg ) + : conflictingPath( conflictingPathArg ) + , conflictingFilesystem( conflictingFilesystemArg ) + , conflictedPath( conflictedPathArg ) + , allowableFilesystems( allowableFilesystemsArg ) +{} + +void +PartitionViewStep::initPartitionCoreModule() +{ + Q_ASSERT( m_core ); + m_core->init(); +} + +void +PartitionViewStep::continueLoading() +{ + Q_ASSERT( !m_choicePage ); + m_choicePage = new ChoicePage( m_config ); + m_choicePage->init( m_core ); + m_widget->addWidget( m_choicePage ); + + // Instantiate the manual partitioning page as needed. + // + Q_ASSERT( !m_manualPartitionPage ); + // m_manualPartitionPage = new PartitionPage( m_core ); + // m_widget->addWidget( m_manualPartitionPage ); + + m_widget->removeWidget( m_waitingWidget ); + m_waitingWidget->deleteLater(); + m_waitingWidget = nullptr; + + connect( m_core, &PartitionCoreModule::hasRootMountPointChanged, this, &PartitionViewStep::nextPossiblyChanged ); + connect( m_choicePage, &ChoicePage::nextStatusChanged, this, &PartitionViewStep::nextPossiblyChanged ); +} + +PartitionViewStep::~PartitionViewStep() +{ + if ( m_choicePage && m_choicePage->parent() == nullptr ) + { + m_choicePage->deleteLater(); + } + if ( m_manualPartitionPage && m_manualPartitionPage->parent() == nullptr ) + { + m_manualPartitionPage->deleteLater(); + } + delete m_core; +} + +QString +PartitionViewStep::prettyName() const +{ + return tr( "Partitions", "@label" ); +} + +/** @brief Gather the pretty descriptions of all the partitioning jobs + * + * Returns a QStringList of each job's pretty description, including + * duplicates (but no empty lines). The list is in-order of how the + * jobs will be run. If no job has a non-empty description, the list is empty. + */ +static QStringList +jobDescriptions( const Calamares::JobList& jobs ) +{ + QStringList jobsLines; + for ( const Calamares::job_ptr& job : std::as_const( jobs ) ) + { + const auto description = job->prettyDescription(); + if ( !description.isEmpty() ) + { + jobsLines.append( description ); + } + } + return jobsLines; +} + +/** @brief A top-level description of what @p choice does + * + * Returns a translated string describing what @p choice will do. + * Includes branding information. + */ +static QString +modeDescription( Config::InstallChoice choice ) +{ + const auto* branding = Calamares::Branding::instance(); + + switch ( choice ) + { + case Config::InstallChoice::Alongside: + return QCoreApplication::translate( + "PartitionViewStep", "Install %1 alongside another operating system", "@label" ) + .arg( branding->shortVersionedName() ); + case Config::InstallChoice::Erase: + return QCoreApplication::translate( + "PartitionViewStep", "Erase disk and install %1", "@label" ) + .arg( branding->shortVersionedName() ); + case Config::InstallChoice::Replace: + return QCoreApplication::translate( + "PartitionViewStep", "Replace a partition with %1", "@label" ) + .arg( branding->shortVersionedName() ); + case Config::InstallChoice::NoChoice: + case Config::InstallChoice::Manual: + return QCoreApplication::translate( "PartitionViewStep", "Manual partitioning", "@label" ); + } + return QString(); +} + +/** @brief A top-level description of what @p choice does to disk @p info + * + * Returns a translated string, with branding and device information, describing what + * will be done to device @p info when @p choice is made. The @p listLength + * is used to provide context; when more than one disk is in use, the description + * works differently. + */ +static QString +diskDescription( int listLength, const PartitionCoreModule::SummaryInfo& info, Config::InstallChoice choice ) +{ + const auto* branding = Calamares::Branding::instance(); + + if ( listLength == 1 ) // this is the only disk preview + { + switch ( choice ) + { + case Config::Alongside: + return QCoreApplication::translate( + "PartitionViewStep", + "Install %1 alongside another operating system on disk " + "%2 (%3)", + "@info" ) + .arg( branding->shortVersionedName() ) + .arg( info.deviceNode ) + .arg( info.deviceName ); + case Config::Erase: + return QCoreApplication::translate( "PartitionViewStep", + "Erase disk %2 (%3) and install %1", + "@info" ) + .arg( branding->shortVersionedName() ) + .arg( info.deviceNode ) + .arg( info.deviceName ); + case Config::Replace: + return QCoreApplication::translate( + "PartitionViewStep", + "Replace a partition on disk %2 (%3) with %1", + "@info" ) + .arg( branding->shortVersionedName() ) + .arg( info.deviceNode ) + .arg( info.deviceName ); + case Config::NoChoice: + case Config::Manual: + return QCoreApplication::translate( "PartitionViewStep", + "Manual partitioning on disk %1 (%2)", + "@info" ) + .arg( info.deviceNode ) + .arg( info.deviceName ); + } + return QString(); + } + else // multiple disk previews! + { + return QCoreApplication::translate( "PartitionViewStep", "Disk %1 (%2)", "@info" ) + .arg( info.deviceNode ) + .arg( info.deviceName ); + } +} + +QString +PartitionViewStep::prettyStatus() const +{ + const Config::InstallChoice choice = m_config->installChoice(); + const QList< PartitionCoreModule::SummaryInfo > list = m_core->createSummaryInfo(); + + cDebug() << "Summary for Partition" << list.length() << choice; + const QString diskInfoLabel = [ &choice, &list ]() + { + QStringList s; + for ( const auto& i : list ) + { + s.append( diskDescription( 1, i, choice ) ); + } + return s.join( QString() ); + }(); + QStringList jobsLabels = jobDescriptions( jobs() ); + if ( m_config->swapChoice() == Config::SwapChoice::SwapFile ) + { + jobsLabels.append( tr( "Create a swap file." ) ); + } + return diskInfoLabel + "
    " + jobsLabels.join( QStringLiteral( "
    " ) ); +} + +QWidget* +PartitionViewStep::createSummaryWidget() const +{ + QWidget* widget = new QWidget; + QVBoxLayout* mainLayout = new QVBoxLayout; + widget->setLayout( mainLayout ); + Calamares::unmarginLayout( mainLayout ); + + Config::InstallChoice choice = m_config->installChoice(); + + QFormLayout* formLayout = new QFormLayout( widget ); + const int MARGIN = Calamares::defaultFontHeight() / 2; + formLayout->setContentsMargins( MARGIN, 0, MARGIN, MARGIN ); + mainLayout->addLayout( formLayout ); + +#if defined( DEBUG_PARTITION_UNSAFE ) || defined( DEBUG_PARTITION_BAIL_OUT ) || defined( DEBUG_PARTITION_SKIP ) + auto specialRow = [ = ]( Calamares::ImageType t, const QString& s ) + { + QLabel* icon = new QLabel; + icon->setPixmap( Calamares::defaultPixmap( t ) ); + formLayout->addRow( icon, new QLabel( s ) ); + }; +#endif +#if defined( DEBUG_PARTITION_UNSAFE ) + specialRow( Calamares::ImageType::StatusWarning, tr( "Unsafe partition actions are enabled." ) ); +#endif +#if defined( DEBUG_PARTITION_BAIL_OUT ) + specialRow( Calamares::ImageType::Information, tr( "Partitioning is configured to always fail." ) ); +#endif +#if defined( DEBUG_PARTITION_SKIP ) + specialRow( Calamares::ImageType::Information, tr( "No partitions will be changed." ) ); +#endif + + const QList< PartitionCoreModule::SummaryInfo > list = m_core->createSummaryInfo(); + if ( list.length() > 1 ) // There are changes on more than one disk + { + //NOTE: all of this should only happen when Manual partitioning is active. + // Any other choice should result in a list.length() == 1. + QLabel* modeLabel = new QLabel; + formLayout->addRow( modeLabel ); + modeLabel->setText( modeDescription( choice ) ); + } + for ( const auto& info : list ) + { + QLabel* diskInfoLabel = new QLabel; + diskInfoLabel->setText( diskDescription( list.length(), info, choice ) ); + formLayout->addRow( diskInfoLabel ); + + PartitionBarsView* preview; + PartitionLabelsView* previewLabels; + QVBoxLayout* field; + + PartitionBarsView::NestedPartitionsMode mode + = Calamares::JobQueue::instance()->globalStorage()->value( "drawNestedPartitions" ).toBool() + ? PartitionBarsView::DrawNestedPartitions + : PartitionBarsView::NoNestedPartitions; + preview = new PartitionBarsView; + preview->setNestedPartitionsMode( mode ); + previewLabels = new PartitionLabelsView; + previewLabels->setExtendedPartitionHidden( mode == PartitionBarsView::NoNestedPartitions ); + preview->setModel( info.partitionModelBefore ); + previewLabels->setModel( info.partitionModelBefore ); + preview->setSelectionMode( QAbstractItemView::NoSelection ); + previewLabels->setSelectionMode( QAbstractItemView::NoSelection ); + info.partitionModelBefore->setParent( widget ); + field = new QVBoxLayout; + Calamares::unmarginLayout( field ); + field->setSpacing( 6 ); + field->addWidget( preview ); + field->addWidget( previewLabels ); + formLayout->addRow( tr( "Current:", "@label" ), field ); + + preview = new PartitionBarsView; + preview->setNestedPartitionsMode( mode ); + previewLabels = new PartitionLabelsView; + previewLabels->setExtendedPartitionHidden( mode == PartitionBarsView::NoNestedPartitions ); + preview->setModel( info.partitionModelAfter ); + previewLabels->setModel( info.partitionModelAfter ); + preview->setSelectionMode( QAbstractItemView::NoSelection ); + previewLabels->setSelectionMode( QAbstractItemView::NoSelection ); + previewLabels->setCustomNewRootLabel( + Calamares::Branding::instance()->string( Calamares::Branding::BootloaderEntryName ) ); + info.partitionModelAfter->setParent( widget ); + field = new QVBoxLayout; + Calamares::unmarginLayout( field ); + field->setSpacing( 6 ); + field->addWidget( preview ); + field->addWidget( previewLabels ); + formLayout->addRow( tr( "After:", "@label" ), field ); + } + const QStringList jobsLines = jobDescriptions( jobs() ); + if ( !jobsLines.isEmpty() ) + { + QLabel* jobsLabel = new QLabel( widget ); + mainLayout->addWidget( jobsLabel ); + jobsLabel->setText( jobsLines.join( "
    " ) ); + jobsLabel->setMargin( Calamares::defaultFontHeight() / 2 ); + QPalette pal; + pal.setColor( WindowBackground, pal.window().color().lighter( 108 ) ); + jobsLabel->setAutoFillBackground( true ); + jobsLabel->setPalette( pal ); + } + return widget; +} + +QWidget* +PartitionViewStep::widget() +{ + return m_widget; +} + +void +PartitionViewStep::next() +{ + if ( m_choicePage == m_widget->currentWidget() ) + { + if ( m_config->installChoice() == Config::InstallChoice::Manual ) + { + if ( !m_manualPartitionPage ) + { + m_manualPartitionPage = new PartitionPage( m_core, *m_config ); + m_widget->addWidget( m_manualPartitionPage ); + } + + m_widget->setCurrentWidget( m_manualPartitionPage ); + m_manualPartitionPage->selectDeviceByIndex( m_choicePage->lastSelectedDeviceIndex() ); + if ( m_core->isDirty() ) + { + m_manualPartitionPage->onRevertClicked(); + } + } + cDebug() << "Choice applied: " << m_config->installChoice(); + } +} + +void +PartitionViewStep::back() +{ + if ( m_widget->currentWidget() != m_choicePage ) + { + m_widget->setCurrentWidget( m_choicePage ); + m_choicePage->setLastSelectedDeviceIndex( m_manualPartitionPage->selectedDeviceIndex() ); + + if ( m_manualPartitionPage ) + { + m_manualPartitionPage->deleteLater(); + m_manualPartitionPage = nullptr; + } + } +} + +bool +PartitionViewStep::isNextEnabled() const +{ + if ( m_choicePage && m_widget->currentWidget() == m_choicePage ) + { + return m_choicePage->isNextEnabled(); + } + + if ( m_manualPartitionPage && m_widget->currentWidget() == m_manualPartitionPage ) + { + return m_core->hasRootMountPoint(); + } + + return false; +} + +void +PartitionViewStep::nextPossiblyChanged( bool ) +{ + Q_EMIT nextStatusChanged( isNextEnabled() ); +} + +bool +PartitionViewStep::isBackEnabled() const +{ + return true; +} + +bool +PartitionViewStep::isAtBeginning() const +{ + if ( m_widget->currentWidget() != m_choicePage ) + { + return false; + } + return true; +} + +bool +PartitionViewStep::isAtEnd() const +{ + if ( m_widget->currentWidget() == m_choicePage ) + { + auto choice = m_config->installChoice(); + if ( Config::InstallChoice::Erase == choice || Config::InstallChoice::Replace == choice + || Config::InstallChoice::Alongside == choice ) + { + return true; + } + return false; + } + return true; +} + +void +PartitionViewStep::onActivate() +{ + m_config->fillGSSecondaryConfiguration(); + + // if we're coming back to PVS from the next VS + if ( m_widget->currentWidget() == m_choicePage && m_config->installChoice() == Config::InstallChoice::Alongside ) + { + m_choicePage->applyActionChoice( Config::InstallChoice::Alongside ); + // m_choicePage->reset(); + } +} + +static QString +listItem( QString s ) +{ + return s.prepend( QStringLiteral( "
  • " ) ).append( QStringLiteral( "
  • " ) ); +} + +static bool +shouldWarnForGPTOnBIOS( const PartitionCoreModule* core ) +{ + if ( PartUtils::isEfiSystem() ) + { + return false; + } + + const QString biosFlagName = PartitionTable::flagName( KPM_PARTITION_FLAG( BiosGrub ) ); + + auto [ r, device ] = core->bootLoaderModel()->findBootLoader( core->bootLoaderInstallPath() ); + Q_UNUSED( r ); + if ( device ) + { + auto* table = device->partitionTable(); + cDebug() << "Found device for bootloader" << device->deviceNode(); + if ( table && table->type() == PartitionTable::TableType::gpt ) + { + // So this is a BIOS system, and the bootloader will be installed on a GPT system + for ( const auto& partition : std::as_const( table->children() ) ) + { + using Calamares::Units::operator""_MiB; + if ( ( partition->activeFlags() & KPM_PARTITION_FLAG( BiosGrub ) ) + && ( partition->fileSystem().type() == FileSystem::Unformatted ) + && ( partition->capacity() >= 8_MiB ) ) + { + cDebug() << Logger::SubEntry << "Partition" << partition->devicePath() << partition->partitionPath() + << "is a suitable" << biosFlagName << "partition"; + return false; + } + } + } + cDebug() << Logger::SubEntry << "No suitable partition for" << biosFlagName << "found"; + } + else + { + cDebug() << "Found no device for" << core->bootLoaderInstallPath(); + } + return true; +} + +static bool +shouldWarnForNotEncryptedBoot( const Config* config, const PartitionCoreModule* core ) +{ + if ( config->showNotEncryptedBootMessage() ) + { + Partition* root_p = core->findPartitionByMountPoint( "/" ); + Partition* boot_p = core->findPartitionByMountPoint( "/boot" ); + + if ( root_p && boot_p ) + { + const auto encryptionMismatch + = [ root_t = root_p->fileSystem().type(), boot_t = boot_p->fileSystem().type() ]( FileSystem::Type t ) + { return root_t == t && boot_t != t; }; + if ( encryptionMismatch( FileSystem::Luks ) || encryptionMismatch( FileSystem::Luks2 ) ) + { + return true; + } + } + } + return false; +} + +static PartitionViewStep::FSConflictEntry +calcFSConflictEntry( PartitionCoreModule* core, PartitionModel* partModel, QModelIndex partFsIdx, QModelIndex partMountPointIdx, QStringList mountPointList ) +{ + PartitionViewStep::FSConflictEntry result; + + QString partFs = partModel->data( partFsIdx ).toString().toLower(); + QString partMountPoint = partModel->data( partMountPointIdx ).toString(); + FileSystem::Type fsType; + PartUtils::canonicalFilesystemName( partFs, &fsType ); + bool fsTypeIsAllowed = false; + if ( fsType == FileSystem::Type::Unknown ) + { + fsTypeIsAllowed = true; + } + else + { + QList< FileSystem::Type > allowedFsTypes = core->dirFSRestrictLayout().allowedFSTypes( partMountPoint, mountPointList, true ); + for ( const auto& allowedFsType : allowedFsTypes ) + { + if ( fsType == allowedFsType ) + { + fsTypeIsAllowed = true; + break; + } + } + } + + if ( !fsTypeIsAllowed ) + { + QString conflictedPath = core->dirFSRestrictLayout().diagnoseFSConflict( partMountPoint, fsType, mountPointList ); + QList< FileSystem::Type > nonConflictingFilesystemTypes = core->dirFSRestrictLayout().allowedFSTypes( conflictedPath, mountPointList, true ); + QStringList nonConflictingFilesystems; + for ( const auto& fsType : nonConflictingFilesystemTypes ) + { + nonConflictingFilesystems.append( Calamares::Partition::prettyNameForFileSystemType( fsType ) ); + } + result = PartitionViewStep::FSConflictEntry( partMountPoint, partFs, conflictedPath, nonConflictingFilesystems ); + } + + return result; +} + +static QList< PartitionViewStep::FSConflictEntry > +checkForFilesystemConflicts( PartitionCoreModule* core ) +{ + QList< PartitionViewStep::FSConflictEntry > result; + + DeviceModel* dm = core->deviceModel(); + QStringList mountPointList; + + // Walk the device and partition tree, extracting mountpoints from it + for ( int i = 0; i < dm->rowCount(); i++ ) + { + Device* dev = dm->deviceForIndex( dm->index( i ) ); + PartitionModel* pm = core->partitionModelForDevice( dev ); + + QModelIndex extPartMountPointIdx = QModelIndex(); + bool extPartFound = false; + for ( int j = 0; j < pm->rowCount(); j++ ) + { + QModelIndex partFsIdx = pm->index( j, PartitionModel::FileSystemColumn ); + QModelIndex partMountPointIdx = pm->index( j, PartitionModel::MountPointColumn ); + + if ( pm->data( partFsIdx ).toString().toLower() == "extended" ) + { + extPartFound = true; + extPartMountPointIdx = partMountPointIdx; + break; + } + + QString mountPoint = pm->data( partMountPointIdx ).toString(); + if ( !mountPoint.isEmpty() ) + { + mountPointList.append( mountPoint ); + } + } + if ( extPartFound ) + { + for ( int j = 0; j < pm->rowCount( extPartMountPointIdx ); j++ ) + { + QModelIndex partMountPointIdx = pm->index( j, PartitionModel::MountPointColumn, extPartMountPointIdx ); + QString mountPoint = pm->data( partMountPointIdx ).toString(); + if ( !mountPoint.isEmpty() ) + { + mountPointList.append( mountPoint ); + } + } + } + } + + // Walk the device and partition tree again, validating it this time + for ( int i = 0; i < dm->rowCount(); i++ ) + { + Device* dev = dm->deviceForIndex( dm->index( i ) ); + PartitionModel* pm = core->partitionModelForDevice( dev ); + + QModelIndex extPartFsIdx = QModelIndex(); + QModelIndex extPartMountPointIdx = QModelIndex(); + bool extPartFound = false; + + for ( int j = 0; j < pm->rowCount(); j++ ) + { + QModelIndex partFsIdx = pm->index( j, PartitionModel::FileSystemColumn ); + QModelIndex partMountPointIdx = pm->index( j, PartitionModel::MountPointColumn ); + + if ( pm->data( partFsIdx ).toString().toLower() == "extended" ) + { + extPartFound = true; + extPartFsIdx = partFsIdx; + extPartMountPointIdx = partMountPointIdx; + break; + } + + PartitionViewStep::FSConflictEntry conflictEntry = calcFSConflictEntry( core, pm, partFsIdx, partMountPointIdx, mountPointList ); + if ( !conflictEntry.conflictedPath.isEmpty() ) + { + result.append( conflictEntry ); + } + } + if ( extPartFound ) + { + for ( int j = 0; j < pm->rowCount( extPartFsIdx ); j++ ) + { + QModelIndex partFsIdx = pm->index( j, PartitionModel::FileSystemColumn, extPartFsIdx ); + QModelIndex partMountPointIdx = pm->index( j, PartitionModel::MountPointColumn, extPartMountPointIdx ); + PartitionViewStep::FSConflictEntry conflictEntry = calcFSConflictEntry( core, pm, partFsIdx, partMountPointIdx, mountPointList ); + if ( !conflictEntry.conflictedPath.isEmpty() ) + { + result.append( conflictEntry ); + } + } + } + } + + return result; +} + +void +PartitionViewStep::onLeave() +{ + if ( m_widget->currentWidget() == m_choicePage ) + { + m_choicePage->onLeave(); + return; + } + + const auto* branding = Calamares::Branding::instance(); + + const QString startList = QStringLiteral( "

      " ); + const QString endList = QStringLiteral( "


    " ); + + if ( m_widget->currentWidget() == m_manualPartitionPage ) + { + if ( PartUtils::isEfiSystem() ) + { + const QString espMountPoint + = Calamares::JobQueue::instance()->globalStorage()->value( "efiSystemPartition" ).toString(); + Partition* esp = m_core->findPartitionByMountPoint( espMountPoint ); + + QString message; + QString description; + + Logger::Once o; + + const bool okType = esp && PartUtils::isEfiFilesystemSuitableType( esp ); + const bool okRecommendedSize = esp && PartUtils::isEfiFilesystemRecommendedSize( esp ); + const bool okMinimumSize = esp && PartUtils::isEfiFilesystemMinimumSize( esp ); + const bool okFlag = esp && PartUtils::isEfiBootable( esp ); + + const bool espExistsButIsWrong = esp && !( okType && okMinimumSize && okFlag ); + + const QString genericWrongnessMessage = tr( "An EFI system partition is necessary to start %1." + "

    " + "To configure an EFI system partition, go back and " + "select or create a suitable filesystem." ) + .arg( branding->shortProductName() ); + const QString genericRecommendationMessage + = tr( "An EFI system partition is necessary to start %1." + "

    " + "The EFI system partition does not meet recommendations. It is " + "recommended to go back and " + "select or create a suitable filesystem." ) + .arg( branding->shortProductName() ); + + const QString wrongMountPointMessage + = tr( "The filesystem must be mounted on %1." ).arg( espMountPoint ); + const QString wrongTypeMessage = tr( "The filesystem must have type FAT32." ); + const QString wrongFlagMessage = tr( "The filesystem must have flag %1 set." ) + .arg( PartitionTable::flagName( PartitionTable::Flag::Boot ) ); + + const auto recommendedMiB = Calamares::BytesToMiB( PartUtils::efiFilesystemRecommendedSize() ); + const auto minimumMiB = Calamares::BytesToMiB( PartUtils::efiFilesystemMinimumSize() ); + + // Three flavors of size-is-wrong + const QString requireConfiguredSize + = tr( "The filesystem must be at least %1 MiB in size." ).arg( recommendedMiB ); + const QString requiredMinimumSize + = tr( "The filesystem must be at least %1 MiB in size." ).arg( minimumMiB ); + const QString suggestConfiguredSize + = tr( "The minimum recommended size for the filesystem is %1 MiB." ).arg( recommendedMiB ); + + const QString mayFail = tr( "You can continue without setting up an EFI system " + "partition but your system may fail to start." ); + const QString possibleFail = tr( "You can continue with this EFI system " + "partition configuration but your system may fail to start." ); + + if ( !esp ) + { + cDebug() << o << "No ESP mounted"; + message = tr( "No EFI system partition configured" ); + + description = genericWrongnessMessage + startList + listItem( wrongMountPointMessage ) + + listItem( requireConfiguredSize ) + listItem( wrongTypeMessage ) + listItem( wrongFlagMessage ) + + endList + mayFail; + } + else if ( espExistsButIsWrong ) + { + message = tr( "EFI system partition configured incorrectly" ); + + description = genericWrongnessMessage + startList; + if ( !okMinimumSize ) + { + description.append( listItem( requiredMinimumSize ) ); + } + if ( !okType ) + { + description.append( listItem( wrongTypeMessage ) ); + } + if ( !okFlag ) + { + description.append( listItem( wrongFlagMessage ) ); + } + description.append( endList ); + description.append( mayFail ); + } + else if ( !okRecommendedSize ) + { + message = tr( "EFI system partition recommendation" ); + description = genericRecommendationMessage + suggestConfiguredSize + possibleFail; + } + + if ( !message.isEmpty() ) + { + QMessageBox mb( QMessageBox::Warning, message, description, QMessageBox::Ok, m_manualPartitionPage ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); + } + } + else + { + + cDebug() << "device: BIOS"; + + if ( shouldWarnForGPTOnBIOS( m_core ) ) + { + const QString biosFlagName = PartitionTable::flagName( KPM_PARTITION_FLAG( BiosGrub ) ); + QString message = tr( "Option to use GPT on BIOS" ); + QString description = tr( "A GPT partition table is the best option for all " + "systems. This installer supports such a setup for " + "BIOS systems too." + "

    " + "To configure a GPT partition table on BIOS, " + "(if not done so already) go back " + "and set the partition table to GPT, next create a 8 MB " + "unformatted partition with the " + "%2 flag enabled.

    " + "An unformatted 8 MB partition is necessary " + "to start %1 on a BIOS system with GPT." ) + .arg( branding->shortProductName(), biosFlagName ); + + QMessageBox mb( + QMessageBox::Information, message, description, QMessageBox::Ok, m_manualPartitionPage ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); + } + } + + if ( shouldWarnForNotEncryptedBoot( m_config, m_core ) ) + { + QString message = tr( "Boot partition not encrypted" ); + QString description = tr( "A separate boot partition was set up together with " + "an encrypted root partition, but the boot partition " + "is not encrypted." + "

    " + "There are security concerns with this kind of " + "setup, because important system files are kept " + "on an unencrypted partition.
    " + "You may continue if you wish, but filesystem " + "unlocking will happen later during system startup." + "
    To encrypt the boot partition, go back and " + "recreate it, selecting Encrypt " + "in the partition creation window." ); + + QMessageBox mb( QMessageBox::Warning, message, description, QMessageBox::Ok, m_manualPartitionPage ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); + } + + QList< FSConflictEntry > conflictMap = checkForFilesystemConflicts( m_core ); + if ( !conflictMap.isEmpty() ) + { + QString message = tr( "Filesystem conflicts found" ); + const QString descHeader = tr( "The chosen manual partitioning layout does not " + "comply with the filesystem restrictions set by the " + "distro. The following issues were found:"); + + QStringList issueList; + for ( const auto& entry : conflictMap ) + { + QString buildString; + if ( entry.conflictedPath == "any" ) + { + buildString = tr( "The %1 directory uses filesystem %2, but this distro only allows the following filesystems: %3." ) + .arg( entry.conflictingPath ) + .arg( entry.conflictingFilesystem ) + .arg( entry.allowableFilesystems.join( ", " ) ); + issueList.append( buildString ); + } + else + { + buildString = tr( "The %1 directory uses filesystem %2, but the %3 directory must use one of the following filesystems: %4." ) + .arg( entry.conflictingPath ) + .arg( entry.conflictingFilesystem ) + .arg( entry.conflictedPath ) + .arg( entry.allowableFilesystems.join( ", " ) ); + issueList.append( buildString ); + } + } + + const QString descFooter = tr( "You can continue without setting up filesystems " + "properly, but your system may fail to start." ); + + QString description = descHeader + startList; + for ( const auto& item : issueList ) + { + description += listItem( item ); + } + description += endList + descFooter; + + QMessageBox mb( QMessageBox::Warning, message, description, QMessageBox::Ok, m_manualPartitionPage ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); + } + } +} + +void +PartitionViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); + + // Copy the efiSystemPartition setting to the global storage. It is needed not only in + // the EraseDiskPage, but also in the bootloader configuration modules (grub, bootloader). + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + // Read and parse key swapPartitionName + if ( configurationMap.contains( "swapPartitionName" ) ) + { + gs->insert( "swapPartitionName", Calamares::getString( configurationMap, "swapPartitionName" ) ); + } + + // OTHER SETTINGS + // + gs->insert( "drawNestedPartitions", Calamares::getBool( configurationMap, "drawNestedPartitions", false ) ); + gs->insert( "alwaysShowPartitionLabels", + Calamares::getBool( configurationMap, "alwaysShowPartitionLabels", true ) ); + gs->insert( "enableLuksAutomatedPartitioning", + Calamares::getBool( configurationMap, "enableLuksAutomatedPartitioning", true ) ); + + QString partitionTableName = Calamares::getString( configurationMap, "defaultPartitionTableType" ); + if ( partitionTableName.isEmpty() ) + { + cWarning() << "Partition-module setting *defaultPartitionTableType* is unset, " + "will use gpt for efi or msdos for bios"; + } + gs->insert( "defaultPartitionTableType", partitionTableName ); + gs->insert( "createHybridBootloaderLayout", + Calamares::getBool( configurationMap, "createHybridBootloaderLayout", false ) ); + + // Now that we have the config, we load the PartitionCoreModule in the background + // because it could take a while. Then when it's done, we can set up the widgets + // and remove the spinner. + m_future = new QFutureWatcher< void >(); + connect( m_future, + &QFutureWatcher< void >::finished, + this, + [ this ] + { + continueLoading(); + this->m_future->deleteLater(); + this->m_future = nullptr; + } ); + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + QFuture< void > future = QtConcurrent::run( this, &PartitionViewStep::initPartitionCoreModule ); +#else + QFuture< void > future = QtConcurrent::run( &PartitionViewStep::initPartitionCoreModule, this ); +#endif + m_future->setFuture( future ); + + m_core->partitionLayout().init( m_config->defaultFsType(), configurationMap.value( "partitionLayout" ).toList() ); + m_core->dirFSRestrictLayout().init( configurationMap.value( "directoryFilesystemRestrictions" ).toList() ); +} + +Calamares::JobList +PartitionViewStep::jobs() const +{ + return m_core->jobs( m_config ); +} + +Calamares::RequirementsList +PartitionViewStep::checkRequirements() +{ + if ( m_future ) + { + m_future->waitForFinished(); + } + + Calamares::RequirementsList l; + l.append( { + QLatin1String( "partitions" ), + [] { return tr( "has at least one disk device available." ); }, + [] { return tr( "There are no partitions to install on." ); }, + m_core->deviceModel()->rowCount() > 0, // satisfied +#ifdef DEBUG_PARTITION_UNSAFE + false // optional +#else + true // required +#endif + } ); + + return l; +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( PartitionViewStepFactory, registerPlugin< PartitionViewStep >(); ) diff --git a/calamares/src/modules/partition/PartitionViewStep.h b/calamares/src/modules/partition/PartitionViewStep.h new file mode 100644 index 0000000..8224154 --- /dev/null +++ b/calamares/src/modules/partition/PartitionViewStep.h @@ -0,0 +1,104 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONVIEWSTEP_H +#define PARTITIONVIEWSTEP_H + +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include "DllMacro.h" + +#include +#include + +class ChoicePage; +class Config; +class PartitionPage; +class PartitionCoreModule; +class QStackedWidget; +class WaitingWidget; + +template < typename T > +class QFutureWatcher; + +/** + * The starting point of the module. Instantiates PartitionCoreModule, + * ChoicePage and PartitionPage, then connects them. + */ +class PLUGINDLLEXPORT PartitionViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + struct FSConflictEntry + { + QString conflictingPath; + QString conflictingFilesystem; + QString conflictedPath; + QStringList allowableFilesystems; + + FSConflictEntry(); + FSConflictEntry( const QString& conflictingPathArg, + const QString& conflictingFilesystemArg, + const QString& conflictedPathArg, + QStringList allowableFilesystemsArg ); + FSConflictEntry( const FSConflictEntry& e ) = default; + }; + + explicit PartitionViewStep( QObject* parent = nullptr ); + ~PartitionViewStep() override; + + QString prettyName() const override; + QString prettyStatus() const override; + QWidget* createSummaryWidget() const override; + + QWidget* widget() override; + + void next() override; + void back() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onActivate() override; + void onLeave() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + Calamares::JobList jobs() const override; + + Calamares::RequirementsList checkRequirements() override; + +private: + void initPartitionCoreModule(); + void continueLoading(); + + /// "slot" for changes to next-status from the KPMCore and ChoicePage + void nextPossiblyChanged( bool ); + + Config* m_config; + + PartitionCoreModule* m_core; + QStackedWidget* m_widget; + ChoicePage* m_choicePage; + PartitionPage* m_manualPartitionPage; + + WaitingWidget* m_waitingWidget; + QFutureWatcher< void >* m_future; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( PartitionViewStepFactory ) + +#endif // PARTITIONVIEWSTEP_H diff --git a/calamares/src/modules/partition/README.md b/calamares/src/modules/partition/README.md new file mode 100644 index 0000000..d9d0d87 --- /dev/null +++ b/calamares/src/modules/partition/README.md @@ -0,0 +1,96 @@ +#Architecture + + + +## Overview + +The heart of the module is the PartitionCoreModule class. It holds Qt models for +the various elements and can create Calamares jobs representing the changes to +be performed at install time. + +PartitionPage is the main UI class. It represents the module main page, the one +with the device combo box, partition list and action buttons. It reacts to the +buttons by creating various dialogs (the (...)Dialog classes) and tell +PartitionCoreModule what to do. + + +## Use of KPMcore + +This module depends on KPMcore, the same library used by [KDE Partition Manager][kpm]. + +[kpm]: http://sourceforge.net/projects/partitionman/ + + +## Partition and PartitionInfo + +Calamares needs to store some information about partitions which is not +available in Partition Manager's Partition class. + +This includes the install mount point and a boolean to mark whether an existing +partition should be formatted. + +Reusing the existing `Partition::mountPoint` property was not an option because +it stores the directory where a partition is currently mounted, which is a +different concept from the directory where the user wants the partition to be +mounted on the installed system. We can't hijack this to store our install mount +point because whether the partition is currently mounted is an important +information which should be taken into account later to prevent any modification +on an installed partition. + +The way this extra information is stored is a bit unusual: the functions in the +PartitionInfo namespace takes advantage of Qt dynamic properties methods to add +Calamares-specific properties to the Partition instances: setting the install +mount point is done with `PartitionInfo::setMountPoint(partition, "/")`, +retrieving it is done with `mountPoint = PartitionInfo::mountPoint(partition)`. + +The rationale behind this unusual design is simplicity: the alternative would +have been to keep a separate PartitionInfo object and a map linking each +Partition to its PartitionInfo instance. Such a design makes things more +complicated. It complicates memory management: if a Partition goes away, its +matching PartitionInfo must be removed. It also leads to uglier APIs: code which +needs access to extra partition information must be passed both Partition and +PartitionInfo instances or know a way to get a PartitionInfo from a Partition. + +The other alternative would have been to add Calamares-specific information to +the real Partition object. This would have worked and would have made for a less +surprising API, but it would mean more Calamares-specific patches on KPMcore. + + +#Tests + +The module comes with unit tests for the partition jobs. Those tests need to +run on storage device which does not contain any data you care about. + +To build them: + + cd $top_build_dir + make buildtests + +To run them you need to define the `CALAMARES_TEST_DISK` environment variable. +It should contain the device path to the test disk. For example, assuming you +plugged a test USB stick identified as `/dev/sdb`, you would run the tests like +this: + + sudo CALAMARES_TEST_DISK=/dev/sdb $top_build_dir/partitionjobtests + + +#TODO + +- Support resizing extended partitions. ResizePartitionJob should already + support this but the UI prevents editing of extended partitions for now. + +- Use os-prober to find out the installed OS. This information could then be + used in PartitionModel and in the partition views. + +- PartitionBarsView + - Show used space + - Highlight selected partition + - Make the partitions clickable + - Match appearance with PartResizerWidget appearance + +- Expose PartitionInfo::format in PartitionModel and add a column for it in the + tree view diff --git a/calamares/src/modules/partition/core/BootLoaderModel.cpp b/calamares/src/modules/partition/core/BootLoaderModel.cpp new file mode 100644 index 0000000..1e5e75f --- /dev/null +++ b/calamares/src/modules/partition/core/BootLoaderModel.cpp @@ -0,0 +1,215 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2021 Anubhav Choudhary + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "core/BootLoaderModel.h" + +#include "core/KPMHelpers.h" +#include "core/PartitionInfo.h" + +#include "utils/Logger.h" + +// KPMcore +#include +#include + +#include + +static QStandardItem* +createBootLoaderItem( const QString& description, const QString& path, bool isPartition ) +{ + QStandardItem* item = new QStandardItem( description ); + item->setData( path, BootLoaderModel::BootLoaderPathRole ); + item->setData( isPartition, BootLoaderModel::IsPartitionRole ); + return item; +} + +BootLoaderModel::BootLoaderModel( QObject* parent ) + : QStandardItemModel( parent ) +{ +} + +BootLoaderModel::~BootLoaderModel() {} + +void +BootLoaderModel::init( const QList< Device* >& devices ) +{ + beginResetModel(); + blockSignals( true ); + + m_devices = devices; + updateInternal(); + + blockSignals( false ); + endResetModel(); +} + +void +BootLoaderModel::createMbrItems() +{ + for ( auto device : m_devices ) + { + QString text = tr( "Master Boot Record of %1", "@info" ).arg( device->name() ); + appendRow( createBootLoaderItem( text, device->deviceNode(), false ) ); + } +} + +void +BootLoaderModel::update() +{ + beginResetModel(); + blockSignals( true ); + updateInternal(); + blockSignals( false ); + endResetModel(); +} + + +void +BootLoaderModel::updateInternal() +{ + QMutexLocker lock( &m_lock ); + clear(); + createMbrItems(); + + // An empty model is possible if you don't have permissions: don't crash though. + if ( rowCount() < 1 ) + { + return; + } + + QString partitionText; + Partition* partition = KPMHelpers::findPartitionByMountPoint( m_devices, "/boot" ); + if ( partition ) + { + partitionText = tr( "Boot Partition", "@info" ); + } + else + { + partition = KPMHelpers::findPartitionByMountPoint( m_devices, "/" ); + if ( partition ) + { + partitionText = tr( "System Partition", "@info" ); + } + } + + Q_ASSERT( rowCount() > 0 ); + QStandardItem* last = item( rowCount() - 1 ); + Q_ASSERT( last ); + bool lastIsPartition = last->data( IsPartitionRole ).toBool(); + + if ( !partition ) + { + if ( lastIsPartition ) + { + takeRow( rowCount() - 1 ); + } + } + else + { + QString mountPoint = PartitionInfo::mountPoint( partition ); + if ( lastIsPartition ) + { + last->setText( partitionText ); + last->setData( mountPoint, BootLoaderPathRole ); + } + else + { + appendRow( createBootLoaderItem( partitionText, PartitionInfo::mountPoint( partition ), true ) ); + } + } + // Create "don't install bootloader" item. This is always available, + // also if there was no /boot or / partition found. + appendRow( createBootLoaderItem( tr( "Do not install a boot loader", "@label" ), QString(), false ) ); +} + + +QVariant +BootLoaderModel::data( const QModelIndex& index, int role ) const +{ + QMutexLocker lock( &m_lock ); + if ( role == Qt::DisplayRole ) + { + QString displayRole = QStandardItemModel::data( index, Qt::DisplayRole ).toString(); + QString pathRole = QStandardItemModel::data( index, BootLoaderModel::BootLoaderPathRole ).toString(); + if ( pathRole.isEmpty() ) + { + return displayRole; + } + + return tr( "%1 (%2)" ).arg( displayRole, pathRole ); + } + return QStandardItemModel::data( index, role ); +} + +std::pair< int, Device* > +BootLoaderModel::findBootLoader( const QString& path ) const +{ + int r = 0; + for ( Device* d : m_devices ) + { + if ( d && d->deviceNode() == path ) + { + return std::make_pair( r, d ); + } + r++; + } + + Partition* partition = KPMHelpers::findPartitionByMountPoint( m_devices, path ); + if ( partition ) + { + const QString partition_device_path = partition->deviceNode(); + r = 0; + for ( Device* d : m_devices ) + { + if ( d && d->deviceNode() == partition_device_path ) + { + return std::make_pair( r, d ); + } + r++; + } + } + return std::make_pair( -1, nullptr ); +} + + +namespace Calamares +{ +void +restoreSelectedBootLoader( QComboBox& combo, const QString& path ) +{ + const auto* model = combo.model(); + if ( model->rowCount() < 1 ) + { + cDebug() << "No items in BootLoaderModel"; + return; + } + + if ( path.isEmpty() ) + { + cDebug() << "No path to restore, choosing default"; + combo.setCurrentIndex( 0 ); + return; + } + + const BootLoaderModel* bmodel = qobject_cast< const BootLoaderModel* >( model ); + int r = bmodel ? bmodel->findBootLoader( path ).first : -1; + if ( r >= 0 ) + { + combo.setCurrentIndex( r ); + } + else + { + combo.setCurrentIndex( 0 ); + } +} + +} // namespace Calamares diff --git a/calamares/src/modules/partition/core/BootLoaderModel.h b/calamares/src/modules/partition/core/BootLoaderModel.h new file mode 100644 index 0000000..e640d4d --- /dev/null +++ b/calamares/src/modules/partition/core/BootLoaderModel.h @@ -0,0 +1,75 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef BOOTLOADERMODEL_H +#define BOOTLOADERMODEL_H + +#include +#include +#include + +class Device; +class QComboBox; + +/** + * This model contains one entry for each device MBR plus one entry for the + * /boot or / partition + */ +class BootLoaderModel : public QStandardItemModel +{ + Q_OBJECT +public: + using DeviceList = QList< Device* >; + + enum + { + BootLoaderPathRole = Qt::UserRole + 1, + IsPartitionRole + }; + + BootLoaderModel( QObject* parent = nullptr ); + ~BootLoaderModel() override; + + /** + * Init the model with the list of devices. Does *not* take ownership of the + * devices. + */ + void init( const DeviceList& devices ); + + void update(); + + QVariant data( const QModelIndex& index, int role = Qt::DisplayRole ) const override; + + /** @brief Looks up a boot-loader by device-name @p path (e.g. /dev/sda) + * + * Returns a row number (index) in the model and a Device*: if there **is** a + * device for the given @p path, index will be in range of the model and + * Device* non-null. Returns (-1, nullptr) otherwise. + */ + std::pair< int, Device* > findBootLoader( const QString& path ) const; + +private: + DeviceList m_devices; + mutable QMutex m_lock; + + void createMbrItems(); + void updateInternal(); +}; + +namespace Calamares +{ +/** @brief Tries to set @p path as selected item in @p combo + * + * Matches a boot-loader install path (e.g. /dev/sda) with a model + * row and sets that as the current row. + */ +void restoreSelectedBootLoader( QComboBox& combo, const QString& path ); +} // namespace Calamares +#endif /* BOOTLOADERMODEL_H */ diff --git a/calamares/src/modules/partition/core/ColorUtils.cpp b/calamares/src/modules/partition/core/ColorUtils.cpp new file mode 100644 index 0000000..6dc17be --- /dev/null +++ b/calamares/src/modules/partition/core/ColorUtils.cpp @@ -0,0 +1,197 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "core/ColorUtils.h" + +#include "core/KPMHelpers.h" + +#include "partition/PartitionIterator.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" + +// KPMcore +#include +#include + +// Qt +#include +#include + +using Calamares::Partition::isPartitionFreeSpace; +using Calamares::Partition::isPartitionNew; +using Calamares::Partition::PartitionIterator; + +static const int NUM_PARTITION_COLORS = 5; +static const int NUM_NEW_PARTITION_COLORS = 4; +//Let's try to use the Breeze palette +static const QColor PARTITION_COLORS[ NUM_PARTITION_COLORS ] = { + "#2980b9", //Dark Plasma Blue + "#27ae60", //Dark Icon Green + "#c9ce3b", //Dirty Yellow + "#3daee9", //Plasma Blue + "#9b59b6", //Purple +}; +static const QColor NEW_PARTITION_COLORS[ NUM_NEW_PARTITION_COLORS ] = { + "#c0392b", //Dark Icon Red + "#f39c1f", //Dark Icon Yellow + "#f1b7bc", //Light Salmon + "#fed999", //Light Orange +}; +static QColor FREE_SPACE_COLOR = "#777777"; +static QColor EXTENDED_COLOR = "#aaaaaa"; +static QColor UNKNOWN_DISKLABEL_COLOR = "#4d4151"; + +static QMap< QString, QColor > s_partitionColorsCache; + + +namespace ColorUtils +{ + +QColor +freeSpaceColor() +{ + return FREE_SPACE_COLOR; +} + +QColor +unknownDisklabelColor() +{ + return UNKNOWN_DISKLABEL_COLOR; +} + +PartitionNode* +_findRootForPartition( PartitionNode* partition ) +{ + if ( partition->isRoot() || !partition->parent() ) + { + return partition; + } + + return _findRootForPartition( partition->parent() ); +} + +QColor +colorForPartition( Partition* partition ) +{ + if ( !partition ) + { + cWarning() << "NULL partition"; + return FREE_SPACE_COLOR; + } + + if ( isPartitionFreeSpace( partition ) ) + { + return FREE_SPACE_COLOR; + } + if ( partition->roles().has( PartitionRole::Extended ) ) + { + return EXTENDED_COLOR; + } + + if ( partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() ) + { + if ( partition->fileSystem().type() == FileSystem::Luks || partition->fileSystem().type() == FileSystem::Luks2 ) + { + FS::luks& luksFs = dynamic_cast< FS::luks& >( partition->fileSystem() ); + if ( !luksFs.outerUuid().isEmpty() && s_partitionColorsCache.contains( luksFs.outerUuid() ) ) + { + return s_partitionColorsCache[ luksFs.outerUuid() ]; + } + } + + if ( s_partitionColorsCache.contains( partition->fileSystem().uuid() ) ) + { + return s_partitionColorsCache[ partition->fileSystem().uuid() ]; + } + } + + // No partition-specific color needed, pick one from our list, but skip + // free space: we don't want a partition to change colors if space before + // it is inserted or removed + PartitionNode* parent = _findRootForPartition( partition ); + PartitionTable* table = dynamic_cast< PartitionTable* >( parent ); + Q_ASSERT( table ); + int colorIdx = 0; + int newColorIdx = 0; + for ( PartitionIterator it = PartitionIterator::begin( table ); it != PartitionIterator::end( table ); ++it ) + { + Partition* child = *it; + if ( child == partition ) + { + break; + } + if ( !isPartitionFreeSpace( child ) && !child->hasChildren() ) + { + if ( isPartitionNew( child ) ) + { + ++newColorIdx; + } + ++colorIdx; + } + } + + if ( isPartitionNew( partition ) ) + { + return NEW_PARTITION_COLORS[ newColorIdx % NUM_NEW_PARTITION_COLORS ]; + } + + if ( partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() ) + { + if ( partition->fileSystem().type() == FileSystem::Luks || partition->fileSystem().type() == FileSystem::Luks2 ) + { + FS::luks& luksFs = dynamic_cast< FS::luks& >( partition->fileSystem() ); + if ( !luksFs.outerUuid().isEmpty() ) + { + s_partitionColorsCache.insert( luksFs.outerUuid(), + PARTITION_COLORS[ colorIdx % NUM_PARTITION_COLORS ] ); + } + } + else + { + s_partitionColorsCache.insert( partition->fileSystem().uuid(), + PARTITION_COLORS[ colorIdx % NUM_PARTITION_COLORS ] ); + } + } + return PARTITION_COLORS[ colorIdx % NUM_PARTITION_COLORS ]; +} + + +QColor +colorForPartitionInFreeSpace( Partition* partition ) +{ + PartitionNode* parent = _findRootForPartition( partition ); + PartitionTable* table = dynamic_cast< PartitionTable* >( parent ); + Q_ASSERT( table ); + int newColorIdx = 0; + for ( PartitionIterator it = PartitionIterator::begin( table ); it != PartitionIterator::end( table ); ++it ) + { + Partition* child = *it; + if ( child == partition ) + { + break; + } + if ( !isPartitionFreeSpace( child ) && !child->hasChildren() && isPartitionNew( child ) ) + { + ++newColorIdx; + } + } + return NEW_PARTITION_COLORS[ newColorIdx % NUM_NEW_PARTITION_COLORS ]; +} + + +void +invalidateCache() +{ + s_partitionColorsCache.clear(); +} + +} // namespace ColorUtils diff --git a/calamares/src/modules/partition/core/ColorUtils.h b/calamares/src/modules/partition/core/ColorUtils.h new file mode 100644 index 0000000..9ebce58 --- /dev/null +++ b/calamares/src/modules/partition/core/ColorUtils.h @@ -0,0 +1,49 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef COLORUTILS_H +#define COLORUTILS_H + +class QColor; + +class Partition; + +/** + * Helper functions to define colors for partitions. It ensures no consecutive + * partitions have the same color. + */ +namespace ColorUtils +{ + +QColor freeSpaceColor(); + +QColor unknownDisklabelColor(); + +/** + * @brief colorForPartition iterates over partitions, caches their colors and returns + * a color for the given partition. + * @param partition the partition for which to return a color. + * @return a color for the partition. + */ +QColor colorForPartition( Partition* partition ); + +/** + * This is similar to colorForPartition() but returns the color of a partition + * which would be created in freeSpacePartition + */ +QColor colorForPartitionInFreeSpace( Partition* freeSpacePartition ); + +/** + * @brief invalidateCache clears the partition colors cache. + */ +void invalidateCache(); + +} // namespace ColorUtils + +#endif /* COLORUTILS_H */ diff --git a/calamares/src/modules/partition/core/DeviceList.cpp b/calamares/src/modules/partition/core/DeviceList.cpp new file mode 100644 index 0000000..16723c7 --- /dev/null +++ b/calamares/src/modules/partition/core/DeviceList.cpp @@ -0,0 +1,197 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "DeviceList.h" + +#include "partition/PartitionIterator.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include +#include +#include +#include + +#include + +using Calamares::Partition::PartitionIterator; + +namespace PartUtils +{ + +/** + * Does the given @p device contain the root filesystem? This is true if + * the device contains a partition which is currently mounted at / . + */ +static bool +hasRootPartition( Device* device ) +{ + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + if ( ( *it )->mountPoint() == "/" ) + { + return true; + } + } + return false; +} + +/** @brief Check if @p path holds an iso9660 filesystem + * + * The @p path should point to a device; blkid is used to check the FS type. + */ +static bool +blkIdCheckIso9660( const QString& path ) +{ + // If blkid fails, there's no output, but we don't care + auto r = Calamares::System::runCommand( { "blkid", path }, std::chrono::seconds( 30 ) ); + return r.getOutput().contains( "iso9660" ); +} + +/// @brief Convenience to check if @p partition holds an iso9660 filesystem +static bool +blkIdCheckIso9660P( const Partition* partition ) +{ + return blkIdCheckIso9660( partition->partitionPath() ); +} + +/** @brief Check if the @p device is an iso9660 device + * + * An iso9660 device is **probably** a CD-ROM. If the device holds an + * iso9660 FS, or any of its partitions do, then we call it an iso9660 device. + */ +static bool +isIso9660( const Device* device ) +{ + const QString path = device->deviceNode(); + if ( path.isEmpty() ) + { + return false; + } + if ( blkIdCheckIso9660( path ) ) + { + return true; + } + + if ( device->partitionTable() && !device->partitionTable()->children().isEmpty() ) + { + const auto& p = device->partitionTable()->children(); + return std::any_of( p.cbegin(), p.cend(), blkIdCheckIso9660P ); + } + return false; +} + +static inline bool +isZRam( const Device* device ) +{ + const QString path = device->deviceNode(); + return path.startsWith( "/dev/zram" ); +} + +static inline bool +isFloppyDrive( const Device* device ) +{ + const QString path = device->deviceNode(); + return path.startsWith( "/dev/fd" ) || path.startsWith( "/dev/floppy" ); +} + +static inline QDebug& +operator<<( QDebug& s, QList< Device* >::iterator& it ) +{ + s << ( ( *it ) ? ( *it )->deviceNode() : QString( "" ) ); + return s; +} + +using DeviceList = QList< Device* >; + +static inline DeviceList::iterator +erase( DeviceList& l, DeviceList::iterator& it ) +{ + Device* p = *it; + auto r = l.erase( it ); + delete p; + return r; +} + +QList< Device* > +getDevices( DeviceType which ) +{ + CoreBackend* backend = CoreBackendManager::self()->backend(); + if ( !backend ) + { + cWarning() << "No KPM backend found."; + return {}; + } + DeviceList devices = backend->scanDevices( /* not includeReadOnly, not includeLoopback */ ScanFlag( 0 ) ); + + /* The list of devices is cleaned up for use: + * - some devices can **never** be used (e.g. floppies, nullptr) + * - some devices can be used if unsafe mode is on, but not in normal operation + * Two lambda's are defined, + * - removeInAllModes() + * - removeInSafeMode() + * To handle the difference. + */ +#ifdef DEBUG_PARTITION_UNSAFE + cWarning() << "Allowing unsafe partitioning choices." << devices.count() << "candidates."; +#ifdef DEBUG_PARTITION_BAIL_OUT + cDebug() << Logger::SubEntry << "unsafe partitioning has been lamed, and will fail."; +#endif + + // Unsafe partitioning + auto removeInAllModes = []( DeviceList& l, DeviceList::iterator& it ) { return erase( l, it ); }; + auto removeInSafeMode = []( DeviceList&, DeviceList::iterator& it ) { return ++it; }; +#else + // Safe partitioning + auto removeInAllModes = []( DeviceList& l, DeviceList::iterator& it ) { return erase( l, it ); }; + auto& removeInSafeMode = removeInAllModes; +#endif + + cDebug() << "Removing unsuitable devices:" << devices.count() << "candidates."; + + bool writableOnly = ( which == DeviceType::WritableOnly ); + // Remove the device which contains / from the list + for ( DeviceList::iterator it = devices.begin(); it != devices.end(); ) + { + if ( !( *it ) ) + { + cDebug() << Logger::SubEntry << "Skipping nullptr device"; + it = removeInAllModes( devices, it ); + } + else if ( isZRam( *it ) ) + { + cDebug() << Logger::SubEntry << "Removing zram" << it; + it = removeInAllModes( devices, it ); + } + else if ( isFloppyDrive( ( *it ) ) ) + { + cDebug() << Logger::SubEntry << "Removing floppy disk" << it; + it = removeInAllModes( devices, it ); + } + else if ( writableOnly && hasRootPartition( *it ) ) + { + cDebug() << Logger::SubEntry << "Removing device with root filesystem (/) on it" << it; + it = removeInSafeMode( devices, it ); + } + else if ( writableOnly && isIso9660( *it ) ) + { + cDebug() << Logger::SubEntry << "Removing device with iso9660 filesystem (probably a CD) on it" << it; + it = removeInSafeMode( devices, it ); + } + else + { + ++it; + } + } + cDebug() << Logger::SubEntry << "there are" << devices.count() << "devices left."; + return devices; +} + +} // namespace PartUtils diff --git a/calamares/src/modules/partition/core/DeviceList.h b/calamares/src/modules/partition/core/DeviceList.h new file mode 100644 index 0000000..b76a31a --- /dev/null +++ b/calamares/src/modules/partition/core/DeviceList.h @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef DEVICELIST_H +#define DEVICELIST_H + +#include +#include + +class Device; + +namespace PartUtils +{ + +enum class DeviceType +{ + All, + WritableOnly +}; + +/** + * @brief Gets a list of storage devices. + * @param which Can be used to select from all the devices in + * the system, filtering out those that do not meet a criterium. + * If set to WritableOnly, only devices which can be overwritten + * safely are returned (e.g. RO-media are ignored, as are mounted partitions). + * @return a list of Devices meeting this criterium. + */ +QList< Device* > getDevices( DeviceType which = DeviceType::All ); + +} // namespace PartUtils + +#endif // DEVICELIST_H diff --git a/calamares/src/modules/partition/core/DeviceModel.cpp b/calamares/src/modules/partition/core/DeviceModel.cpp new file mode 100644 index 0000000..160cc7b --- /dev/null +++ b/calamares/src/modules/partition/core/DeviceModel.cpp @@ -0,0 +1,150 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "DeviceModel.h" + +#include "core/PartitionModel.h" +#include "core/SizeUtils.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" + +// KPMcore +#include + +#include +#include + +// STL +#include + +static void +sortDevices( DeviceModel::DeviceList& l ) +{ + std::sort( l.begin(), + l.end(), + []( const Device* dev1, const Device* dev2 ) { return dev1->deviceNode() < dev2->deviceNode(); } ); +} + +DeviceModel::DeviceModel( QObject* parent ) + : QAbstractListModel( parent ) +{ +} + +DeviceModel::~DeviceModel() {} + +void +DeviceModel::init( const DeviceList& devices ) +{ + beginResetModel(); + m_devices = devices; + sortDevices( m_devices ); + endResetModel(); +} + +int +DeviceModel::rowCount( const QModelIndex& parent ) const +{ + return parent.isValid() ? 0 : m_devices.count(); +} + +QVariant +DeviceModel::data( const QModelIndex& index, int role ) const +{ + int row = index.row(); + if ( row < 0 || row >= m_devices.count() ) + { + return QVariant(); + } + + Device* device = m_devices.at( row ); + + switch ( role ) + { + case Qt::DisplayRole: + case Qt::ToolTipRole: + if ( device->name().isEmpty() ) + { + return device->deviceNode(); + } + else + { + if ( device->logicalSize() >= 0 && device->totalLogical() >= 0 ) + { + //: device[name] - size[number] (device-node[name]) + return tr( "%1 - %2 (%3)" ) + .arg( device->name() ) + .arg( formatByteSize( device->capacity() ) ) + .arg( device->deviceNode() ); + } + else + { + // Newly LVM VGs don't have capacity property yet (i.e. + // always has 1B capacity), so don't show it for a while. + // + //: device[name] - (device-node[name]) + return tr( "%1 - (%2)" ).arg( device->name() ).arg( device->deviceNode() ); + } + } + case Qt::DecorationRole: + return Calamares::defaultPixmap( + Calamares::PartitionDisk, + Calamares::Original, + QSize( Calamares::defaultIconSize().width() * 2, Calamares::defaultIconSize().height() * 2 ) ); + default: + return QVariant(); + } +} + +Device* +DeviceModel::deviceForIndex( const QModelIndex& index ) const +{ + int row = index.row(); + if ( row < 0 || row >= m_devices.count() ) + { + return nullptr; + } + return m_devices.at( row ); +} + +void +DeviceModel::swapDevice( Device* oldDevice, Device* newDevice ) +{ + Q_ASSERT( oldDevice ); + Q_ASSERT( newDevice ); + + int indexOfOldDevice = m_devices.indexOf( oldDevice ); + if ( indexOfOldDevice < 0 ) + { + return; + } + + m_devices[ indexOfOldDevice ] = newDevice; + + Q_EMIT dataChanged( index( indexOfOldDevice ), index( indexOfOldDevice ) ); +} + +void +DeviceModel::addDevice( Device* device ) +{ + beginResetModel(); + m_devices << device; + sortDevices( m_devices ); + endResetModel(); +} + +void +DeviceModel::removeDevice( Device* device ) +{ + beginResetModel(); + m_devices.removeAll( device ); + sortDevices( m_devices ); + endResetModel(); +} diff --git a/calamares/src/modules/partition/core/DeviceModel.h b/calamares/src/modules/partition/core/DeviceModel.h new file mode 100644 index 0000000..71918f6 --- /dev/null +++ b/calamares/src/modules/partition/core/DeviceModel.h @@ -0,0 +1,53 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef DEVICEMODEL_H +#define DEVICEMODEL_H + +#include +#include +#include + +class Device; +class PartitionModel; + +/** + * A Qt model which exposes a list of Devices. + */ +class DeviceModel : public QAbstractListModel +{ + Q_OBJECT +public: + DeviceModel( QObject* parent = nullptr ); + ~DeviceModel() override; + + using DeviceList = QList< Device* >; + + /** + * Init the model with the list of devices. Does *not* take ownership of the + * devices. + */ + void init( const DeviceList& devices ); + + int rowCount( const QModelIndex& parent = QModelIndex() ) const override; + QVariant data( const QModelIndex& index, int role = Qt::DisplayRole ) const override; + + Device* deviceForIndex( const QModelIndex& index ) const; + + void swapDevice( Device* oldDevice, Device* newDevice ); + + void addDevice( Device* device ); + + void removeDevice( Device* device ); + +private: + DeviceList m_devices; +}; + +#endif /* DEVICEMODEL_H */ diff --git a/calamares/src/modules/partition/core/DirFSRestrictLayout.cpp b/calamares/src/modules/partition/core/DirFSRestrictLayout.cpp new file mode 100644 index 0000000..3ccc8e4 --- /dev/null +++ b/calamares/src/modules/partition/core/DirFSRestrictLayout.cpp @@ -0,0 +1,231 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2018-2019 Collabora Ltd + * SPDX-FileCopyrightText: 2024 Aaron Rainbolt + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "utils/Logger.h" + +#include "core/DirFSRestrictLayout.h" + +#include "core/KPMHelpers.h" +#include "core/PartUtils.h" + +#include "utils/Variant.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include +#include + +#include + +DirFSRestrictLayout::DirFSRestrictLayout() {} + +DirFSRestrictLayout::DirFSRestrictLayout( const DirFSRestrictLayout& layout ) + : m_dirFSRestrictLayout( layout.m_dirFSRestrictLayout ) +{ +} + +DirFSRestrictLayout::~DirFSRestrictLayout() {} + +DirFSRestrictLayout::DirFSRestrictEntry::DirFSRestrictEntry( const QString& path, + QList< FileSystem::Type > allowedFSTypes, + bool onlyWhenMountpoint ) + : dirPath( path ) + , dirAllowedFSTypes( allowedFSTypes ) + , useOnlyWhenMountpoint( onlyWhenMountpoint ) +{ +} + +void +DirFSRestrictLayout::init( const QVariantList& config ) +{ + m_dirFSRestrictLayout.clear(); + bool efiNeedsSet = true; + + for ( const auto& r : config ) + { + QVariantMap pentry = r.toMap(); + if ( !pentry.contains( "directory" ) || !pentry.contains( "allowedFilesystemTypes" ) ) + { + cError() << "Directory filesystem restriction layout entry #" << config.indexOf( r ) + << "lacks mandatory attributes, switching to default layout."; + m_dirFSRestrictLayout.clear(); + break; + } + + QString directory = Calamares::getString( pentry, "directory" ); + QStringList allowedFSTypeStrings = Calamares::getStringList( pentry, "allowedFilesystemTypes" ); + QList< FileSystem::Type > allowedFSTypes; + if ( allowedFSTypeStrings.length() == 1 && allowedFSTypeStrings[0] == "all" ) + { + allowedFSTypes = fullFSList(); + } + else + { + for ( const auto& fsStr : allowedFSTypeStrings ) + { + FileSystem::Type allowedFSType; + PartUtils::canonicalFilesystemName( fsStr, &allowedFSType ); + if ( allowedFSType == FileSystem::Type::Unknown ) + { + continue; + } + allowedFSTypes.append( allowedFSType ); + } + } + bool onlyWhenMountpoint = Calamares::getBool( pentry, "onlyWhenMountpoint", false ); + if ( directory == "efi" ) + { + efiNeedsSet = false; + } + DirFSRestrictEntry restrictEntry( directory, allowedFSTypes, onlyWhenMountpoint ); + m_dirFSRestrictLayout.append( restrictEntry ); + } + + if ( efiNeedsSet ) + { + QList< FileSystem::Type > efiAllowedFSTypes = { FileSystem::Fat32 }; + DirFSRestrictEntry efiRestrictEntry( "efi", efiAllowedFSTypes, true ); + m_dirFSRestrictLayout.append( efiRestrictEntry ); + } +} + +QList< FileSystem::Type > +DirFSRestrictLayout::allowedFSTypes( const QString& path, const QStringList& existingMountpoints, bool overlayDirs ) +{ + QSet< FileSystem::Type > typeSet; + bool foundTypeList = false; + + for ( const auto& entry : m_dirFSRestrictLayout ) + { + QString dirPath = entry.dirPath; + if ( dirPath == "efi" ) + { + dirPath = Calamares::JobQueue::instance()->globalStorage()->value( "efiSystemPartition" ).toString(); + } + if ( dirPath == path || ( !entry.useOnlyWhenMountpoint && overlayDirs && path.startsWith( QStringLiteral( "/" ) ) && dirPath.startsWith( path ) && !existingMountpoints.contains( dirPath ) ) ) + { + QSet< FileSystem::Type > newTypeSet = QSet< FileSystem::Type >( entry.dirAllowedFSTypes.cbegin(), entry.dirAllowedFSTypes.cend() ); + foundTypeList = true; + if ( typeSet.isEmpty() ) + { + typeSet = newTypeSet; + if ( !overlayDirs ) + { + break; + } + } + else + { + typeSet.intersect( newTypeSet ); + } + } + } + + if ( overlayDirs ) + { + QList< FileSystem::Type > anyTypeList = anyAllowedFSTypes(); + QSet< FileSystem::Type > anyTypeSet = QSet< FileSystem::Type >( anyTypeList.cbegin(), anyTypeList.cend() ); + if ( !foundTypeList ) + { + typeSet = anyTypeSet; + foundTypeList = true; + } + else + { + typeSet.intersect( anyTypeSet ); + } + } + + if ( foundTypeList ) + { + return QList< FileSystem::Type >( typeSet.cbegin(), typeSet.cend() ); + } + else + { + // This directory doesn't have any allowed filesystems explicitly + // configured, so all filesystems are valid. + return fullFSList(); + } +} + +QString +DirFSRestrictLayout::diagnoseFSConflict( const QString& path, const FileSystem::Type& fsType, const QStringList& existingMountpoints ) +{ + QSet< FileSystem::Type > typeSet; + bool foundTypeList = false; + + for ( const auto& entry : m_dirFSRestrictLayout ) + { + QString dirPath = entry.dirPath; + if ( dirPath == "efi" ) + { + dirPath = Calamares::JobQueue::instance()->globalStorage()->value( "efiSystemPartition" ).toString(); + } + if ( dirPath == path || ( !entry.useOnlyWhenMountpoint && path.startsWith( QStringLiteral( "/" ) ) && ( dirPath.startsWith( path ) || dirPath == "any" ) && !existingMountpoints.contains( dirPath ) ) ) + { + QSet< FileSystem::Type > newTypeSet = QSet< FileSystem::Type >( entry.dirAllowedFSTypes.cbegin(), entry.dirAllowedFSTypes.cend() ); + foundTypeList = true; + if ( typeSet.isEmpty() ) + { + typeSet = newTypeSet; + } + else + { + typeSet.intersect( newTypeSet ); + } + } + + if ( foundTypeList && !typeSet.contains( fsType ) ) + { + if ( typeSet.isEmpty() ) + { + cWarning() << "no filesystems are valid for path '" << path << "', check directoryFilesystemRestrictions for issues"; + } + // At this point, we've found the first mountpoint that, when + // taken into account, results in the currently chosen filesystem + // being invalid. Return that mountpoint. + return dirPath; + } + } + + return QString(); +} + +QList< FileSystem::Type > +DirFSRestrictLayout::anyAllowedFSTypes() +{ + for ( const auto& entry : m_dirFSRestrictLayout ) + { + if ( entry.dirPath == "any" ) + { + return entry.dirAllowedFSTypes; + } + } + + // No global filesystem whitelist defined, so all filesystems are + // considered valid unless a mountpoint-specific whitelist is used to + // restrict the allowed filesystems. + return fullFSList(); +} + +QList< FileSystem::Type > +DirFSRestrictLayout::fullFSList() +{ + QList< FileSystem::Type > typeList; + FileSystemFactory::init(); + for ( auto fs : FileSystemFactory::map() ) + { + typeList.append( fs->type() ); + } + return typeList; +} diff --git a/calamares/src/modules/partition/core/DirFSRestrictLayout.h b/calamares/src/modules/partition/core/DirFSRestrictLayout.h new file mode 100644 index 0000000..86686f0 --- /dev/null +++ b/calamares/src/modules/partition/core/DirFSRestrictLayout.h @@ -0,0 +1,87 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018-2019 Collabora Ltd + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2024 Aaron Rainbolt + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef DIRFSRESTRICTLAYOUT_H +#define DIRFSRESTRICTLAYOUT_H + +#include "Config.h" + +// KPMcore +#include + +// Qt +#include +#include +#include + +class DirFSRestrictLayout +{ +public: + struct DirFSRestrictEntry + { + QString dirPath; + QList< FileSystem::Type > dirAllowedFSTypes; + bool useOnlyWhenMountpoint = false; + + /// @brief All-zeroes DirFSRestrictEntry + DirFSRestrictEntry() = default; + /** @brief Parse @p path, @p allowedFSTypes, and @p onlyWhenMountpoint to their respective member variables + * + * Sets a specific set of allowed filesystems for a mountpoint. + */ + DirFSRestrictEntry( const QString& path, + QList< FileSystem::Type > allowedFSTypes, + bool onlyWhenMountpoint ); + /// @brief Copy DirFSRestrictEntry + DirFSRestrictEntry( const DirFSRestrictEntry& e ) = default; + }; + + DirFSRestrictLayout(); + DirFSRestrictLayout( const DirFSRestrictLayout& layout ); + ~DirFSRestrictLayout(); + + /** @brief create the configuration from @p config + * + * @p config is a list of partition entries (in QVariant form, + * read from YAML). If no entries are given, the only restriction is that + * the EFI system partition must use fat32. + * + * Any unknown values in the config will be ignored. + */ + void init( const QVariantList& config ); + + /** @brief get a list of allowable filesystems for a path + * + * @p path is the path one wants to get the allowed FS types for. + * @p existingMountpoints is the list of all mountpoints that are + * currently configured to be placed on their own partition. + */ + QList< FileSystem::Type > allowedFSTypes( const QString& path, const QStringList& existingMountpoints, bool overlayDirs ); + + /** @brief determine which directory restriction rule makes a particular mountpoint + filesystem combination invalid + * + * @p path is the path with an improper filesystem chosen. + * @p fsType is the improper filesystem used on that path. + * @p existingMountpoints is the list of all mountpoints that are + * currently configured to be placed on their own partition. + */ + QString diagnoseFSConflict( const QString& path, const FileSystem::Type& fsType, const QStringList& existingMountpoints ); + + /// @brief get a global filesystem whitelist + QList< FileSystem::Type > anyAllowedFSTypes(); + +private: + QList< DirFSRestrictEntry > m_dirFSRestrictLayout; + + QList< FileSystem::Type > fullFSList(); +}; + +#endif /* DIRFSRESTRICTLAYOUT_H */ diff --git a/calamares/src/modules/partition/core/KPMHelpers.cpp b/calamares/src/modules/partition/core/KPMHelpers.cpp new file mode 100644 index 0000000..50fd11d --- /dev/null +++ b/calamares/src/modules/partition/core/KPMHelpers.cpp @@ -0,0 +1,339 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * Copyright 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "core/KPMHelpers.h" + +#include "core/PartitionInfo.h" + +#include "partition/PartitionIterator.h" +#include "utils/Logger.h" +#include "utils/String.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +using Calamares::Partition::PartitionIterator; + +namespace KPMHelpers +{ + +Partition* +findPartitionByMountPoint( const QList< Device* >& devices, const QString& mountPoint ) +{ + for ( auto device : devices ) + { + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + if ( PartitionInfo::mountPoint( *it ) == mountPoint ) + { + return *it; + } + } + } + return nullptr; +} + + +Partition* +createNewPartition( PartitionNode* parent, + const Device& device, + const PartitionRole& role, + FileSystem::Type fsType, + const QString& fsLabel, + qint64 firstSector, + qint64 lastSector, + PartitionTable::Flags flags ) +{ + FileSystem* fs = FileSystemFactory::create( fsType, firstSector, lastSector, device.logicalSize() ); + fs->setLabel( fsLabel ); + return new Partition( parent, + device, + role, + fs, + fs->firstSector(), + fs->lastSector(), + QString() /* path */, + KPM_PARTITION_FLAG( None ) /* availableFlags */, + QString() /* mountPoint */, + false /* mounted */, + flags /* activeFlags */, + KPM_PARTITION_STATE( New ) ); +} + + +Partition* +createNewEncryptedPartition( PartitionNode* parent, + const Device& device, + const PartitionRole& role, + FileSystem::Type fsType, + const QString& fsLabel, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + const QString& passphrase, + PartitionTable::Flags flags ) +{ + PartitionRole::Roles newRoles = role.roles(); + if ( !role.has( PartitionRole::Luks ) ) + { + newRoles |= PartitionRole::Luks; + } + + FileSystem::Type luksType = luksGenerationToFSName( luksFsType ); + + FS::luks* fs = dynamic_cast< FS::luks* >( + FileSystemFactory::create( luksType, firstSector, lastSector, device.logicalSize() ) ); + if ( !fs ) + { + cError() << "cannot create LUKS filesystem. Giving up."; + return nullptr; + } + + fs->createInnerFileSystem( fsType ); + fs->setPassphrase( passphrase ); + fs->setLabel( fsLabel ); + Partition* p = new Partition( parent, + device, + PartitionRole( newRoles ), + fs, + fs->firstSector(), + fs->lastSector(), + QString() /* path */, + KPM_PARTITION_FLAG( None ) /* availableFlags */, + QString() /* mountPoint */, + false /* mounted */, + flags /* activeFlags */, + KPM_PARTITION_STATE( New ) ); + return p; +} + + +Partition* +clonePartition( Device* device, Partition* partition ) +{ + FileSystem* fs = FileSystemFactory::create( + partition->fileSystem().type(), partition->firstSector(), partition->lastSector(), device->logicalSize() ); + return new Partition( partition->parent(), + *device, + partition->roles(), + fs, + fs->firstSector(), + fs->lastSector(), + partition->partitionPath(), + partition->activeFlags() ); +} + +SavePassphraseValue +savePassphrase( Partition* partition, const QString& passphrase ) +{ + + if ( passphrase.isEmpty() ) + { + return SavePassphraseValue::EmptyPassphrase; + } + + FS::luks* luksFs = dynamic_cast< FS::luks* >( &partition->fileSystem() ); + if ( luksFs == nullptr ) + { + // No luks device + return SavePassphraseValue::NotLuksPartition; + } + + // Test the given passphrase + if ( !luksFs->testPassphrase( partition->partitionPath(), passphrase ) ) + { + // Save the existing passphrase + luksFs->setPassphrase( passphrase ); + } + else + { + return SavePassphraseValue::IncorrectPassphrase; + } + return SavePassphraseValue::NoError; +} + +// Adapted from src/fs/luks.cpp cryptOpen which always opens a dialog to ask for a passphrase +QString +cryptOpen( Partition* partition ) +{ + FS::luks* luksFs = dynamic_cast< FS::luks* >( &partition->fileSystem() ); + if ( luksFs == nullptr ) + { + // No luks device + return QString(); + } + + if ( luksFs->isCryptOpen() ) + { + if ( !luksFs->mapperName().isEmpty() ) + { + // Already decrypted + return luksFs->mapperName(); + } + else + { + cDebug() << Logger::SubEntry << "No mapper node found - reset cryptOpen"; + luksFs->setCryptOpen( false ); + } + } + + if ( luksFs->passphrase().isEmpty() ) + { + // No passphrase for decryption + return QString(); + } + + // Decrypt the partition + const QString deviceNode = partition->partitionPath(); + ExternalCommand openCmd( QStringLiteral( "cryptsetup" ), + { QStringLiteral( "open" ), deviceNode, luksFs->suggestedMapperName( deviceNode ) } ); + if ( ( openCmd.write( luksFs->passphrase().toLocal8Bit() + '\n' ) && openCmd.start( -1 ) + && openCmd.exitCode() == 0 ) ) + { + luksFs->scan( deviceNode ); + if ( luksFs->mapperName().isEmpty() ) + { + return QString(); + } + luksFs->loadInnerFileSystem( luksFs->mapperName() ); + luksFs->setCryptOpen( luksFs->innerFS() != nullptr ); + if ( !luksFs->isCryptOpen() ) + { + return QString(); + } + return luksFs->mapperName(); + } + return QString(); +} + +void +cryptClose( Partition* partition ) +{ + FS::luks* luksFs = dynamic_cast< FS::luks* >( &partition->fileSystem() ); + if ( luksFs == nullptr ) + { + // No luks device + return; + } + + if ( luksFs->mapperName().isEmpty() ) + { + // Not opened + return; + } + + // Close the partition + luksFs->cryptClose( partition->partitionPath() ); +} + +bool +cryptLabel( Partition* partition, const QString& label ) +{ + int version = cryptVersion( partition ); + if ( version == 0 || label.isEmpty() ) + { + return false; + } + + if ( version == 1 ) + { + QString mappedDevice = cryptOpen( partition ); + if ( !mappedDevice.isEmpty() ) + { + // Label mapped device + ExternalCommand openCmd( QStringLiteral( "e2label" ), { mappedDevice, label } ); + openCmd.start( -1 ); + cryptClose( partition ); + return true; + } + } + else + { + ExternalCommand openCmd( + QStringLiteral( "cryptsetup" ), + { QStringLiteral( "config" ), partition->partitionPath(), QStringLiteral( "--label" ), label } ); + if ( openCmd.start( -1 ) && openCmd.exitCode() == 0 ) + { + return true; + } + } + return false; +} + +int +cryptVersion( Partition* partition ) +{ + if ( partition->fileSystem().type() != FileSystem::Luks ) + { + return 0; + } + + // Get luks version from header information + int luksVersion = 1; + ExternalCommand openCmd( QStringLiteral( "cryptsetup" ), + { QStringLiteral( "luksDump" ), partition->partitionPath() } ); + if ( openCmd.start( -1 ) && openCmd.exitCode() == 0 ) + { + QRegularExpression re( QStringLiteral( R"(version:\s+(\d))" ), QRegularExpression::CaseInsensitiveOption ); + QRegularExpressionMatch rem = re.match( openCmd.output() ); + if ( rem.hasMatch() ) + { + luksVersion = rem.captured( 1 ).toInt(); + } + } + return luksVersion; +} + +FileSystem::Type +luksGenerationToFSName( Config::LuksGeneration luksGeneration ) +{ + // Convert luksGenerationChoice from partition.conf into its + // corresponding file system type from KPMCore. + switch ( luksGeneration ) + { + case Config::LuksGeneration::Luks2: + return FileSystem::Type::Luks2; + case Config::LuksGeneration::Luks1: + return FileSystem::Type::Luks; + default: + cWarning() << "luksGeneration not supported, defaulting to \"luks\""; + return FileSystem::Type::Luks; + } +} + + +Calamares::JobResult +execute( Operation& operation, const QString& failureMessage ) +{ + operation.setStatus( Operation::StatusRunning ); + + Report report( nullptr ); + if ( operation.execute( report ) ) + { + return Calamares::JobResult::ok(); + } + + // Remove the === lines from the report by trimming them to empty + QStringList l = report.toText().split( '\n' ); + std::for_each( l.begin(), l.end(), []( QString& s ) { Calamares::String::removeLeading( s, '=' ); } ); + + return Calamares::JobResult::error( failureMessage, l.join( '\n' ) ); +} + + +} // namespace KPMHelpers diff --git a/calamares/src/modules/partition/core/KPMHelpers.h b/calamares/src/modules/partition/core/KPMHelpers.h new file mode 100644 index 0000000..b8e6fca --- /dev/null +++ b/calamares/src/modules/partition/core/KPMHelpers.h @@ -0,0 +1,167 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef KPMHELPERS_H +#define KPMHELPERS_H + +#include "Config.h" +#include "Job.h" + +#include +#include +#include +#include + +#include + +#include + +class Device; +class Partition; +class PartitionNode; +class PartitionRole; + +// TODO:3.3: Remove defines, expand in-place +#define KPM_PARTITION_FLAG( x ) PartitionTable::Flag::x +#define KPM_PARTITION_STATE( x ) Partition::State::x +#define KPM_PARTITION_FLAG_ESP PartitionTable::Flag::Boot + +/** + * Helper functions to manipulate partitions + */ +namespace KPMHelpers +{ + +/** @brief Return (errors) for savePassphrase() + * + * There's a handful of things that can go wrong when + * saving a passphrase for a given partition; this + * expresses clearly which ones are wrong. + * + * @c NoError is "Ok" when saving the passphrase succeeds. + */ +enum class SavePassphraseValue +{ + NoError, + EmptyPassphrase, + NotLuksPartition, + IncorrectPassphrase, + CryptsetupError, + NoMapperNode, + DeviceNotDecrypted +}; + +/** + * Iterates on all devices and return the first partition which is associated + * with mountPoint. This uses PartitionInfo::mountPoint(), not Partition::mountPoint() + */ +Partition* findPartitionByMountPoint( const QList< Device* >& devices, const QString& mountPoint ); + +/** + * Helper function to create a new Partition object (does not create anything + * on the disk) associated with a FileSystem. + */ +Partition* createNewPartition( PartitionNode* parent, + const Device& device, + const PartitionRole& role, + FileSystem::Type fsType, + const QString& fsLabel, + qint64 firstSector, + qint64 lastSector, + PartitionTable::Flags flags ); + +Partition* createNewEncryptedPartition( PartitionNode* parent, + const Device& device, + const PartitionRole& role, + FileSystem::Type fsType, + const QString& fsLabel, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + const QString& passphrase, + PartitionTable::Flags flags ); + +Partition* clonePartition( Device* device, Partition* partition ); + +/** @brief Save an existing passphrase for a previously encrypted partition. + * + * Tries to apply the passphrase to the partition; this checks if the + * @p partition is one that can have a passphrase applied, and + * runs `cryptsetup` to check that the passphrase actually works + * for the partition. Returns `NoError` on success, or an explanatory + * other value if it fails. + */ +SavePassphraseValue savePassphrase( Partition* partition, const QString& passphrase ); + +/** @brief Decrypt an encrypted partition. + * + * Uses @p partition to decrypt the partition. + * The passphrase saved in @p partition is used. + * Returns the mapped device path or an empty string if it fails. + */ +QString cryptOpen( Partition* partition ); +void cryptClose( Partition* partition ); + +/** @brief Set label of luks encrypted partition. + * + * Returns true on success or false if it fails. + */ +bool cryptLabel( Partition* partition, const QString& label ); + +/** @brief Returns the luks version used to encrypt the partition. + * + * Used by cryptLabel + */ +int cryptVersion( Partition* partition ); + +/** @brief Convert a luksGeneration into its FS type for KPMCore. + * + * Will convert Luks1 into FileSystem::Type::luks and Luks2 into + * FileSystem::Type::luks2 for KPMCore partitioning functions. + * + * @return The LUKS FS type (default @c luks ) + */ +FileSystem::Type luksGenerationToFSName( Config::LuksGeneration luksGeneration ); + + +/** @brief Return a result for an @p operation + * + * Executes the operation, and if successful, returns a success result. + * Otherwise returns an error using @p failureMessage as the primary part + * of the error, and details obtained from the operation. + */ +Calamares::JobResult execute( Operation& operation, const QString& failureMessage ); +/** @brief Return a result for an @p operation + * + * It's acceptable to use an rvalue: the operation-running is the effect + * you're interested in, rather than keeping the temporary around. + */ +static inline Calamares::JobResult +execute( Operation&& operation, const QString& failureMessage ) +{ + return execute( operation, failureMessage ); +} + +/** @brief Is this an MSDOS partition table? + * + * Deals with KPMcore deprecations in the TableType enum. + */ +inline bool isMSDOSPartition(PartitionTable::TableType t) +{ +#if WITH_KPMcore > 0x240801 + return t == PartitionTable::TableType::msdos; +#else + return t == PartitionTable::TableType::msdos || t == PartitionTable::TableType::msdos_sectorbased; +#endif +} + +} // namespace KPMHelpers + +#endif /* KPMHELPERS_H */ diff --git a/calamares/src/modules/partition/core/OsproberEntry.cpp b/calamares/src/modules/partition/core/OsproberEntry.cpp new file mode 100644 index 0000000..4a59f7d --- /dev/null +++ b/calamares/src/modules/partition/core/OsproberEntry.cpp @@ -0,0 +1,63 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019, 2024 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "OsproberEntry.h" + + +bool +FstabEntry::isValid() const +{ + return !partitionNode.isEmpty() && !mountPoint.isEmpty() && !fsType.isEmpty(); +} + +FstabEntry +FstabEntry::fromEtcFstab( const QString& rawLine ) +{ + QString line = rawLine.simplified(); + if ( line.startsWith( '#' ) ) + { + return FstabEntry { QString(), QString(), QString(), QString(), 0, 0 }; + } + + QStringList splitLine = line.split( ' ' ); + if ( splitLine.length() != 6 ) + { + return FstabEntry { QString(), QString(), QString(), QString(), 0, 0 }; + } + + return FstabEntry { + splitLine.at( 0 ), // path, or UUID, or LABEL, etc. + splitLine.at( 1 ), // mount point + splitLine.at( 2 ), // fs type + splitLine.at( 3 ), // options + splitLine.at( 4 ).toInt(), //dump + splitLine.at( 5 ).toInt() //pass + }; +} + +namespace Calamares +{ +FstabEntryList +fromEtcFstabContents( const QStringList& fstabLines ) +{ + FstabEntryList fstabEntries; + + for ( const QString& rawLine : fstabLines ) + { + fstabEntries.append( FstabEntry::fromEtcFstab( rawLine ) ); + } + const auto invalidEntries = std::remove_if( + fstabEntries.begin(), fstabEntries.end(), []( const FstabEntry& x ) { return !x.isValid(); } ); + fstabEntries.erase( invalidEntries, fstabEntries.end() ); + return fstabEntries; +} + +} // namespace Calamares diff --git a/calamares/src/modules/partition/core/OsproberEntry.h b/calamares/src/modules/partition/core/OsproberEntry.h new file mode 100644 index 0000000..0db7c9a --- /dev/null +++ b/calamares/src/modules/partition/core/OsproberEntry.h @@ -0,0 +1,67 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef OSPROBERENTRY_H +#define OSPROBERENTRY_H + +#include + +struct FstabEntry +{ + QString partitionNode; + QString mountPoint; + QString fsType; + QString options; + int dump; + int pass; + + /// Does this entry make sense and is it complete? + bool isValid() const; // implemented in Partutils.cpp + + /** @brief Create an entry from a live of /etc/fstab + * + * Splits the given string (which ought to follow the format + * of /etc/fstab) and returns a corresponding Fstab entry. + * If the string isn't valid (e.g. comment-line, or broken + * fstab entry) then the entry that is returned is invalid. + */ + static FstabEntry fromEtcFstab( const QString& ); +}; + +typedef QList< FstabEntry > FstabEntryList; + +namespace Calamares +{ +/** @brief Returns valid entries from the lines of a fstab file */ +FstabEntryList fromEtcFstabContents( const QStringList& fstabLines ); + +/** @brief Returns valid entries from the byte-contents of a fstab file */ +inline FstabEntryList +fromEtcFstabContents( const QByteArray& contents ) +{ + return fromEtcFstabContents( QString::fromLocal8Bit( contents ).split( '\n' ) ); +} +} // namespace Calamares + +struct OsproberEntry +{ + QString prettyName; + QString path; + QString file; + QString uuid; + bool canBeResized; + QStringList line; + FstabEntryList fstab; + QString homePath; +}; + +typedef QList< OsproberEntry > OsproberEntryList; + +#endif // OSPROBERENTRY_H diff --git a/calamares/src/modules/partition/core/PartUtils.cpp b/calamares/src/modules/partition/core/PartUtils.cpp new file mode 100644 index 0000000..3652c5c --- /dev/null +++ b/calamares/src/modules/partition/core/PartUtils.cpp @@ -0,0 +1,636 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartUtils.h" + +#include "core/DeviceModel.h" +#include "core/KPMHelpers.h" +#include "core/PartitionInfo.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "partition/Mount.h" +#include "partition/PartitionIterator.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" +#include "utils/RAII.h" +#include "utils/System.h" + +#include +#include +#include +#include + +#include +#include + +using Calamares::Partition::isPartitionFreeSpace; +using Calamares::Partition::isPartitionNew; + +using Calamares::Units::operator""_MiB; + +static constexpr qint64 efiSpecificationHardMinimumSize = 32_MiB; + +namespace PartUtils +{ + +QString +convenienceName( const Partition* const candidate ) +{ + if ( !candidate->mountPoint().isEmpty() ) + { + return candidate->mountPoint(); + } + if ( !candidate->partitionPath().isEmpty() ) + { + return candidate->partitionPath(); + } + if ( !candidate->devicePath().isEmpty() ) + { + return candidate->devicePath(); + } + if ( !candidate->deviceNode().isEmpty() ) + { + return candidate->devicePath(); + } + + QString p; + QTextStream s( &p ); + s << static_cast< const void* >( candidate ); // No good name available, use pointer address + + return p; +} + +/** @brief Get the globalStorage setting for required space. */ +static double +getRequiredStorageGiB( bool& ok ) +{ + return Calamares::JobQueue::instance()->globalStorage()->value( "requiredStorageGiB" ).toDouble( &ok ); +} + +bool +canBeReplaced( Partition* candidate, const Logger::Once& o ) +{ + if ( !candidate ) + { + cDebug() << o << "Partition* is NULL"; + return false; + } + + cDebug() << o << "Checking if" << convenienceName( candidate ) << "can be replaced."; + if ( candidate->isMounted() ) + { + cDebug() << Logger::SubEntry << "NO, it is mounted."; + return false; + } + + bool ok = false; + double requiredStorageGiB = getRequiredStorageGiB( ok ); + if ( !ok ) + { + cDebug() << Logger::SubEntry << "NO, requiredStorageGiB is not set correctly."; + return false; + } + + qint64 availableStorageB = candidate->capacity(); + qint64 requiredStorageB = Calamares::GiBtoBytes( requiredStorageGiB + 0.5 ); + + if ( availableStorageB > requiredStorageB ) + { + cDebug() << o << "Partition" << convenienceName( candidate ) << "authorized for replace install."; + return true; + } + else + { + Logger::CDebug deb; + deb << Logger::SubEntry << "NO, insufficient storage"; + deb << Logger::Continuation << "Required storage B:" << requiredStorageB + << QString( "(%1GiB)" ).arg( requiredStorageGiB ); + deb << Logger::Continuation << "Available storage B:" << availableStorageB + << QString( "(%1GiB)" ).arg( Calamares::BytesToGiB( availableStorageB ) ); + return false; + } +} + +bool +canBeResized( Partition* candidate, const Logger::Once& o ) +{ + if ( !candidate ) + { + cDebug() << o << "Partition* is NULL"; + return false; + } + + if ( !candidate->fileSystem().supportGrow() || !candidate->fileSystem().supportShrink() ) + { + cDebug() << o << "Can not resize" << convenienceName( candidate ) << ", filesystem" + << candidate->fileSystem().name() << "does not support resize."; + return false; + } + + if ( isPartitionFreeSpace( candidate ) ) + { + cDebug() << o << "Can not resize" << convenienceName( candidate ) << ", partition is free space"; + return false; + } + + if ( candidate->isMounted() ) + { + cDebug() << o << "Can not resize" << convenienceName( candidate ) << ", partition is mounted"; + return false; + } + + if ( candidate->roles().has( PartitionRole::Primary ) ) + { + PartitionTable* table = dynamic_cast< PartitionTable* >( candidate->parent() ); + if ( !table ) + { + cDebug() << o << "Can not resize" << convenienceName( candidate ) << ", no partition table found"; + return false; + } + + if ( table->numPrimaries() >= table->maxPrimaries() ) + { + cDebug() << o << "Can not resize" << convenienceName( candidate ) << ", partition table already has" + << table->maxPrimaries() << "primary partitions."; + return false; + } + } + + bool ok = false; + double requiredStorageGiB = getRequiredStorageGiB( ok ); + if ( !ok ) + { + cDebug() << o << "Can not resize" << convenienceName( candidate ) + << ", requiredStorageGiB is not set correctly."; + return false; + } + + // We require a little more for partitioning overhead and swap file + double advisedStorageGiB = requiredStorageGiB + 0.5 + 2.0; + qint64 availableStorageB = candidate->available(); + qint64 advisedStorageB = Calamares::GiBtoBytes( advisedStorageGiB ); + + if ( availableStorageB > advisedStorageB ) + { + cDebug() << o << "Partition" << convenienceName( candidate ) + << "authorized for resize + autopartition install."; + return true; + } + else + { + Logger::CDebug deb; + deb << Logger::SubEntry << "NO, insufficient storage"; + deb << Logger::Continuation << "Required storage B:" << advisedStorageB + << QString( "(%1GiB)" ).arg( advisedStorageGiB ); + deb << Logger::Continuation << "Available storage B:" << availableStorageB + << QString( "(%1GiB)" ).arg( Calamares::BytesToGiB( availableStorageB ) ) << "for" + << convenienceName( candidate ) << "length:" << candidate->length() + << "sectorsUsed:" << candidate->sectorsUsed() << "fsType:" << candidate->fileSystem().name(); + return false; + } +} + +bool +canBeResized( DeviceModel* dm, const QString& partitionPath, const Logger::Once& o ) +{ + if ( partitionPath.startsWith( "/dev/" ) ) + { + for ( int i = 0; i < dm->rowCount(); ++i ) + { + Device* dev = dm->deviceForIndex( dm->index( i ) ); + Partition* candidate = Calamares::Partition::findPartitionByPath( { dev }, partitionPath ); + if ( candidate ) + { + return canBeResized( candidate, o ); + } + } + cWarning() << "Can not resize" << partitionPath << ", no Partition* found."; + return false; + } + else + { + cWarning() << "Can not resize" << partitionPath << ", does not start with /dev"; + return false; + } +} + +static FstabEntryList +lookForFstabEntries( const QString& partitionPath ) +{ + QStringList mountOptions { "ro" }; + + auto r = Calamares::System::runCommand( Calamares::System::RunLocation::RunInHost, + { "blkid", "-s", "TYPE", "-o", "value", partitionPath } ); + if ( r.getExitCode() ) + { + cWarning() << "blkid on" << partitionPath << "failed."; + } + else + { + QString fstype = r.getOutput().trimmed(); + if ( ( fstype == "ext3" ) || ( fstype == "ext4" ) ) + { + mountOptions.append( "noload" ); + } + } + + cDebug() << "Checking device" << partitionPath << "for fstab (fs=" << r.getOutput() << ')'; + + Calamares::Partition::TemporaryMount mount( partitionPath, QString(), mountOptions.join( ',' ) ); + if ( mount.isValid() ) + { + QFile fstabFile( mount.path() + "/etc/fstab" ); + + if ( fstabFile.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + const auto fstabLines = QString::fromLocal8Bit( fstabFile.readAll() ).split( '\n' ); + fstabFile.close(); + + const auto fstabEntries = Calamares::fromEtcFstabContents( fstabLines ); + cDebug() << Logger::SubEntry << "got" << fstabEntries.count() << "fstab entries from" << fstabLines.count() + << "lines in" << fstabFile.fileName(); + return fstabEntries; + } + else + { + cWarning() << "Could not read fstab from mounted fs"; + return {}; + } + } + else + { + cWarning() << "Could not mount existing fs"; + return {}; + } +} + +static QString +findPartitionPathForMountPoint( const FstabEntryList& fstab, const QString& mountPoint ) +{ + if ( fstab.isEmpty() ) + { + return QString(); + } + + for ( const FstabEntry& entry : fstab ) + { + if ( entry.mountPoint == mountPoint ) + { + QProcess readlink; + QString partPath; + + if ( entry.partitionNode.startsWith( "/dev" ) ) // plain dev node + { + partPath = entry.partitionNode; + } + else if ( entry.partitionNode.startsWith( "LABEL=" ) ) + { + partPath = entry.partitionNode.mid( 6 ); + partPath.remove( "\"" ); + partPath.replace( "\\040", "\\ " ); + partPath.prepend( "/dev/disk/by-label/" ); + } + else if ( entry.partitionNode.startsWith( "UUID=" ) ) + { + partPath = entry.partitionNode.mid( 5 ); + partPath.remove( "\"" ); + partPath = partPath.toLower(); + partPath.prepend( "/dev/disk/by-uuid/" ); + } + else if ( entry.partitionNode.startsWith( "PARTLABEL=" ) ) + { + partPath = entry.partitionNode.mid( 10 ); + partPath.remove( "\"" ); + partPath.replace( "\\040", "\\ " ); + partPath.prepend( "/dev/disk/by-partlabel/" ); + } + else if ( entry.partitionNode.startsWith( "PARTUUID=" ) ) + { + partPath = entry.partitionNode.mid( 9 ); + partPath.remove( "\"" ); + partPath = partPath.toLower(); + partPath.prepend( "/dev/disk/by-partuuid/" ); + } + + // At this point we either have /dev/sda1, or /dev/disk/by-something/... + + if ( partPath.startsWith( "/dev/disk/by-" ) ) // we got a fancy node + { + readlink.start( "readlink", { "-en", partPath } ); + if ( !readlink.waitForStarted( 1000 ) ) + { + return QString(); + } + if ( !readlink.waitForFinished( 1000 ) ) + { + return QString(); + } + if ( readlink.exitCode() != 0 || readlink.exitStatus() != QProcess::NormalExit ) + { + return QString(); + } + partPath = QString::fromLocal8Bit( readlink.readAllStandardOutput() ).trimmed(); + } + + return partPath; + } + } + + return QString(); +} + +OsproberEntryList +runOsprober( DeviceModel* dm ) +{ + Logger::Once o; + + QString osproberOutput; + QProcess osprober; + osprober.setProgram( "os-prober" ); + osprober.setProcessChannelMode( QProcess::SeparateChannels ); + osprober.start(); + if ( !osprober.waitForStarted() ) + { + cError() << "os-prober cannot start."; + } + else if ( !osprober.waitForFinished( 60000 ) ) + { + cError() << "os-prober timed out."; + } + else + { + osproberOutput.append( QString::fromLocal8Bit( osprober.readAllStandardOutput() ).trimmed() ); + } + + QStringList osproberCleanLines; + OsproberEntryList osproberEntries; + const auto lines = osproberOutput.split( '\n' ); + for ( const QString& line : lines ) + { + if ( !line.simplified().isEmpty() ) + { + QStringList lineColumns = line.split( ':' ); + QString prettyName; + if ( !lineColumns.value( 1 ).simplified().isEmpty() ) + { + prettyName = lineColumns.value( 1 ).simplified(); + } + else if ( !lineColumns.value( 2 ).simplified().isEmpty() ) + { + prettyName = lineColumns.value( 2 ).simplified(); + } + + QString file, path = lineColumns.value( 0 ).simplified(); + if ( !path.startsWith( "/dev/" ) ) //basic sanity check + { + continue; + } + + // strip extra file after device: /dev/name@/path/to/file + int index = path.indexOf( '@' ); + if ( index != -1 ) + { + file = path.right( path.length() - index - 1 ); + path = path.left( index ); + } + + FstabEntryList fstabEntries = lookForFstabEntries( path ); + QString homePath = findPartitionPathForMountPoint( fstabEntries, "/home" ); + + osproberEntries.append( { prettyName, + path, + file, + QString(), + canBeResized( dm, path, o ), + lineColumns, + fstabEntries, + homePath } ); + osproberCleanLines.append( line ); + } + } + + if ( osproberCleanLines.count() > 0 ) + { + cDebug() << o << "os-prober lines after cleanup:" << Logger::DebugList( osproberCleanLines ); + } + else + { + cDebug() << o << "os-prober gave no output."; + } + + Calamares::JobQueue::instance()->globalStorage()->insert( "osproberLines", osproberCleanLines ); + + return osproberEntries; +} + +bool +isArmSystem() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + return gs->contains( "armInstall" ) && gs->value( "armInstall" ).toBool(); +} + +bool +isEfiSystem() +{ + return isArmSystem() || QDir( "/sys/firmware/efi/efivars" ).exists(); +} + +bool +isEfiFilesystemSuitableType( const Partition* candidate ) +{ + auto type = candidate->fileSystem().type(); + + QT_WARNING_PUSH + QT_WARNING_DISABLE_CLANG( "-Wswitch-enum" ) + switch ( type ) + { + case FileSystem::Type::Fat32: + return true; + case FileSystem::Type::Fat12: + case FileSystem::Type::Fat16: + cWarning() << "FAT12 and FAT16 are probably not supported by EFI"; + return false; + default: + cWarning() << "EFI boot partition must be FAT32"; + return false; + } + QT_WARNING_POP +} + +bool +isEfiFilesystemRecommendedSize( const Partition* candidate ) +{ + auto size = candidate->capacity(); // bytes + if ( size <= 0 ) + { + return false; + } + + if ( size >= efiFilesystemRecommendedSize() ) + { + return true; + } + else + { + cWarning() << "Filesystem for EFI is smaller than recommended (" << size << "bytes)"; + return false; + } +} + +bool +isEfiFilesystemMinimumSize( const Partition* candidate ) +{ + using Calamares::Units::operator""_MiB; + + auto size = candidate->capacity(); // bytes + if ( size <= 0 ) + { + return false; + } + if ( size < efiSpecificationHardMinimumSize ) + { + return false; + } + + if ( size >= efiFilesystemMinimumSize() ) + { + return true; + } + else + { + cWarning() << "Filesystem for EFI is below minimum (" << size << "bytes)"; + return false; + } +} + +bool +isEfiBootable( const Partition* candidate ) +{ + const auto flags = PartitionInfo::flags( candidate ); + + // In KPMCore4, the flags are remapped, and the ESP flag is the same as Boot. + static_assert( KPM_PARTITION_FLAG_ESP == KPM_PARTITION_FLAG( Boot ), "KPMCore API enum changed" ); + return flags.testFlag( KPM_PARTITION_FLAG_ESP ); +} + +QString +efiFilesystemRecommendedSizeGSKey() +{ + return QStringLiteral( "efiSystemPartitionSize_i" ); +} + +qint64 +efiFilesystemRecommendedSize() +{ + const QString key = efiFilesystemRecommendedSizeGSKey(); + + qint64 uefisys_part_sizeB = 300_MiB; + + // The default can be overridden; the key used here comes + // from the partition module Config.cpp + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( gs->contains( key ) ) + { + qint64 v = gs->value( key ).toLongLong(); + uefisys_part_sizeB = v > 0 ? v : 0; + } + // There is a lower limit of what can be configured + if ( uefisys_part_sizeB < efiSpecificationHardMinimumSize ) + { + uefisys_part_sizeB = efiSpecificationHardMinimumSize; + } + return uefisys_part_sizeB; +} + +QString +efiFilesystemMinimumSizeGSKey() +{ + return QStringLiteral( "efiSystemPartitionMinimumSize_i" ); +} + +qint64 +efiFilesystemMinimumSize() +{ + const QString key = efiFilesystemMinimumSizeGSKey(); + + qint64 uefisys_part_sizeB = efiFilesystemRecommendedSize(); + + // The default can be overridden; the key used here comes + // from the partition module Config.cpp + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( gs->contains( key ) ) + { + qint64 v = gs->value( key ).toLongLong(); + uefisys_part_sizeB = v > 0 ? v : 0; + } + // There is a lower limit of what can be configured + return std::max( uefisys_part_sizeB, efiSpecificationHardMinimumSize ); +} + +QString +canonicalFilesystemName( const QString& fsName, FileSystem::Type* fsType ) +{ + cScopedAssignment type( fsType ); + if ( fsName.isEmpty() ) + { + type = FileSystem::Ext4; + return QStringLiteral( "ext4" ); + } + + QStringList fsLanguage { QLatin1String( "C" ) }; // Required language list to turn off localization + + if ( ( type = FileSystem::typeForName( fsName, fsLanguage ) ) != FileSystem::Unknown ) + { + return fsName; + } + + // Second pass: try case-insensitive + const auto fstypes = FileSystem::types(); + for ( FileSystem::Type t : fstypes ) + { + if ( 0 == QString::compare( fsName, FileSystem::nameForType( t, fsLanguage ), Qt::CaseInsensitive ) ) + { + QString fsRealName = FileSystem::nameForType( t, fsLanguage ); + if ( fsType ) + { + *fsType = t; + } + return fsRealName; + } + } + + cWarning() << "Filesystem" << fsName << "not found, using ext4"; + // fsType can be used to check whether fsName was a valid filesystem. + if ( fsType ) + { + *fsType = FileSystem::Unknown; + } +#ifdef DEBUG_FILESYSTEMS + // This bit is for distros who are debugging their settings, and shows + // all the strings that KPMCore is matching against for FS type. + { + Logger::CDebug d; + using TR = Logger::DebugRow< int, QString >; + const auto fstypes = FileSystem::types(); + d << "Available types (" << fstypes.count() << ')'; + for ( FileSystem::Type t : fstypes ) + { + d << TR( static_cast< int >( t ), FileSystem::nameForType( t, fsLanguage ) ); + } + } +#endif + type = FileSystem::Unknown; + return QStringLiteral( "ext4" ); +} + +} // namespace PartUtils diff --git a/calamares/src/modules/partition/core/PartUtils.h b/calamares/src/modules/partition/core/PartUtils.h new file mode 100644 index 0000000..7813a98 --- /dev/null +++ b/calamares/src/modules/partition/core/PartUtils.h @@ -0,0 +1,154 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTUTILS_H +#define PARTUTILS_H + +#include "OsproberEntry.h" +#include "utils/NamedSuffix.h" +#include "utils/Units.h" + +// KPMcore +#include + +// Qt +#include + +class DeviceModel; +class Partition; +namespace Logger +{ +class Once; +} // namespace Logger + +namespace PartUtils +{ + +/** + * @brief Provides a nice human-readable name for @p candidate + * + * The most-specific human-readable name for the partition @p candidate + * is returned (e.g. device name, or partition path). In the worst + * case, a string representation of (void *)candidate is returned. + */ +QString convenienceName( const Partition* const candidate ); + +/** + * @brief canBeReplaced checks whether the given Partition satisfies the criteria + * for replacing it with the new OS. + * @param candidate the candidate partition to replace. + * @param o applied to debug-logging. + * @return true if the criteria are met, otherwise false. + */ +bool canBeReplaced( Partition* candidate, const Logger::Once& o ); + +/** + * @brief canBeReplaced checks whether the given Partition satisfies the criteria + * for resizing (shrinking) it to make room for a new OS. + * @param candidate the candidate partition to resize. + * @param o applied to debug-logging. + * @return true if the criteria are met, otherwise false. + */ +bool canBeResized( Partition* candidate, const Logger::Once& o ); + +/** + * @brief canBeReplaced checks whether the given Partition satisfies the criteria + * for resizing (shrinking) it to make room for a new OS. + * @param dm the DeviceModel instance. + * @param partitionPath the device path of the candidate partition to resize. + * @param o applied to debug-logging. + * @return true if the criteria are met, otherwise false. + */ +bool canBeResized( DeviceModel* dm, const QString& partitionPath, const Logger::Once& o ); + +/** + * @brief runOsprober executes os-prober, parses the output and writes relevant + * data to GlobalStorage. + * @param dm the DeviceModel instance. + * @return a list of os-prober entries, parsed. + */ +OsproberEntryList runOsprober( DeviceModel* dm ); + +/** + * @brief Is this an ARM-based system? Set in the configuration file + */ +bool isArmSystem(); + +/** + * @brief Is this system EFI-enabled? Decides based on /sys/firmware/efi + */ +bool isEfiSystem(); + +/** + * @brief Is the @p partition suitable as an EFI boot partition? + * Checks for filesystem type (FAT32). + */ +bool isEfiFilesystemSuitableType( const Partition* candidate ); + +/** + * @brief Is the @p partition suitable as an EFI boot partition? + * Checks for filesystem size (300MiB, see efi.recommendedSize). + */ +bool isEfiFilesystemRecommendedSize( const Partition* candidate ); + +/** + * @brief Is the @p candidate suitable as an EFI boot partition? + * Checks for filesystem size (32MiB at least, see efi.minimumSize). + */ +bool isEfiFilesystemMinimumSize( const Partition* candidate ); + +/** @brief Returns the minimum size of an EFI boot partition in bytes. + * + * This is determined as 300MiB, based on the FAT32 standard + * and EFI documentation (and not a little discussion in Calamares + * issues about what works, what is effective, and what is mandated + * by the standard and how all of those are different). + * + * This can be configured through the `partition.conf` file, + * key *efi.recommendedSize*, which will then apply to both + * automatic partitioning **and** the warning for manual partitioning. + * + * A minimum of 32MiB (which is bonkers-small) is enforced. + */ +qint64 efiFilesystemRecommendedSize(); + +// Helper for consistency: the GS key used to share the recommended size +QString efiFilesystemRecommendedSizeGSKey(); + +/** @brief Returns the hard-minimum size of an EFI boot partition in bytes. + * + * This is 32MiB, based on the FAT32 standard and EFI documentation. + */ +qint64 efiFilesystemMinimumSize(); + +// Helper for consistency: the GS key used to share the minimum size +QString efiFilesystemMinimumSizeGSKey(); + +/** + * @brief Is the given @p partition bootable in EFI? Depending on + * the partition table layout, this may mean different flags. + */ +bool isEfiBootable( const Partition* candidate ); + +/** @brief translate @p fsName into a recognized name and type + * + * Makes several attempts to translate the string into a + * name that KPMCore will recognize. Returns the canonical + * filesystem name (e.g. asking for "EXT4" will return "ext4"). + * + * The corresponding filesystem type is stored in @p fsType, and + * its value is FileSystem::Unknown if @p fsName is not recognized. + */ +QString canonicalFilesystemName( const QString& fsName, FileSystem::Type* fsType ); + +} // namespace PartUtils + +#endif // PARTUTILS_H diff --git a/calamares/src/modules/partition/core/PartitionActions.cpp b/calamares/src/modules/partition/core/PartitionActions.cpp new file mode 100644 index 0000000..acd1519 --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionActions.cpp @@ -0,0 +1,284 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionActions.h" + +#include "core/KPMHelpers.h" +#include "core/PartUtils.h" +#include "core/PartitionCoreModule.h" +#include "core/PartitionInfo.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/System.h" +#include "utils/Units.h" + +#include +#include + +#include + +using namespace Calamares::Units; + +static quint64 +swapSuggestion( const quint64 availableSpaceB, Config::SwapChoice swap ) +{ + if ( ( swap != Config::SwapChoice::SmallSwap ) && ( swap != Config::SwapChoice::FullSwap ) ) + { + return 0; + } + + // See partition.conf for explanation + quint64 suggestedSwapSizeB = 0; + auto [ availableRamB, overestimationFactor ] = Calamares::System::instance()->getTotalMemoryB(); + + bool ensureSuspendToDisk = swap == Config::SwapChoice::FullSwap; + + // Ramp up quickly to 8GiB, then follow memory size + if ( availableRamB <= 4_GiB ) + { + suggestedSwapSizeB = availableRamB * 2; + } + else if ( availableRamB <= 8_GiB ) + { + suggestedSwapSizeB = 8_GiB; + } + else + { + suggestedSwapSizeB = availableRamB; + } + + // .. top out at 8GiB if we don't care about suspend + if ( !ensureSuspendToDisk ) + { + // TODO: make the _GiB operator return unsigned + suggestedSwapSizeB = qMin( quint64( 8_GiB ), suggestedSwapSizeB ); + } + + // Allow for a fudge factor + suggestedSwapSizeB = quint64( qRound64( qreal( suggestedSwapSizeB ) * overestimationFactor ) ); + + // don't use more than 10% of available space + if ( !ensureSuspendToDisk ) + { + suggestedSwapSizeB = qMin( suggestedSwapSizeB, availableSpaceB / 10 /* 10% is 0.1 */ ); + } + + // TODO: make Units functions work on unsigned + cDebug() << "Suggested swap size:" << Calamares::BytesToGiB( suggestedSwapSizeB ) << "GiB"; + + return suggestedSwapSizeB; +} + +namespace PartitionActions +{ + +void +doAutopartition( PartitionCoreModule* core, Device* dev, Choices::AutoPartitionOptions o ) +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + const bool isEfi = PartUtils::isEfiSystem(); + + bool createHybridBootloaderLayout = false; + if ( gs->contains( "createHybridBootloaderLayout" ) ) { + createHybridBootloaderLayout = gs->value( "createHybridBootloaderLayout" ).toBool(); + } + + // Partition sizes are expressed in MiB, should be multiples of + // the logical sector size (usually 512B). EFI starts with 2MiB + // empty and a EFI boot partition, while BIOS starts at + // the 1MiB boundary (usually sector 2048). + // ARM empty sectors are 16 MiB in size. + const int empty_space_sizeB = PartUtils::isArmSystem() ? 16_MiB : ( isEfi ? 2_MiB : 1_MiB ); + + // Since sectors count from 0, if the space is 2048 sectors in size, + // the first free sector has number 2048 (and there are 2048 sectors + // before that one, numbered 0..2047). + qint64 firstFreeSector = Calamares::bytesToSectors( empty_space_sizeB, dev->logicalSize() ); + + PartitionTable::TableType partType = PartitionTable::nameToTableType( o.defaultPartitionTableType ); + if ( partType == PartitionTable::unknownTableType ) + { + partType = ( isEfi || createHybridBootloaderLayout ) ? PartitionTable::gpt : PartitionTable::msdos; + } + // last usable sector possibly allowing for secondary GPT using 66 sectors (256 entries) + const qint64 lastUsableSector = dev->totalLogical() - ( partType == PartitionTable::gpt ? 67 : 1 ); + + // Looking up the defaultFsType (which should name a filesystem type) + // will log an error and set the type to Unknown if there's something wrong. + FileSystem::Type type = FileSystem::Unknown; + PartUtils::canonicalFilesystemName( o.defaultFsType, &type ); + core->partitionLayout().setDefaultFsType( type == FileSystem::Unknown ? FileSystem::Ext4 : type ); + + core->createPartitionTable( dev, partType ); + + if ( createHybridBootloaderLayout || isEfi ) + { + qint64 uefisys_part_sizeB = PartUtils::efiFilesystemRecommendedSize(); + qint64 efiSectorCount = Calamares::bytesToSectors( uefisys_part_sizeB, dev->logicalSize() ); + Q_ASSERT( efiSectorCount > 0 ); + + // Since sectors count from 0, and this partition is created starting + // at firstFreeSector, we need efiSectorCount sectors, numbered + // firstFreeSector..firstFreeSector+efiSectorCount-1. + qint64 lastSector = firstFreeSector + efiSectorCount - 1; + Partition* efiPartition = KPMHelpers::createNewPartition( dev->partitionTable(), + *dev, + PartitionRole( PartitionRole::Primary ), + FileSystem::Fat32, + QString(), + firstFreeSector, + lastSector, + KPM_PARTITION_FLAG( None ) ); + PartitionInfo::setFormat( efiPartition, true ); + PartitionInfo::setMountPoint( efiPartition, o.efiPartitionMountPoint ); + if ( gs->contains( "efiSystemPartitionName" ) ) + { + efiPartition->setLabel( gs->value( "efiSystemPartitionName" ).toString() ); + } + core->createPartition( dev, efiPartition, KPM_PARTITION_FLAG_ESP ); + firstFreeSector = lastSector + 1; + + if ( createHybridBootloaderLayout ) + { + qint64 bios_part_sizeB = 8_MiB; + qint64 biosSectorCount = Calamares::bytesToSectors( bios_part_sizeB, dev->logicalSize() ); + Q_ASSERT( biosSectorCount > 0 ); + + qint64 lastSector = firstFreeSector + biosSectorCount - 1; + Partition* biosPartition = KPMHelpers::createNewPartition( dev->partitionTable(), + *dev, + PartitionRole( PartitionRole::Primary ), + FileSystem::Unformatted, + QString(), + firstFreeSector, + lastSector, + KPM_PARTITION_FLAG( None ) ); + core->createPartition( dev, biosPartition, KPM_PARTITION_FLAG( BiosGrub ) ); + firstFreeSector = lastSector + 1; + } + } + + const bool mayCreateSwap + = ( o.swap == Config::SwapChoice::SmallSwap ) || ( o.swap == Config::SwapChoice::FullSwap ); + bool shouldCreateSwap = false; + quint64 suggestedSwapSizeB = 0; + + const quint64 sectorSize = quint64( dev->logicalSize() ); + if ( mayCreateSwap ) + { + quint64 availableSpaceB = quint64( lastUsableSector - firstFreeSector + 1 ) * sectorSize; + suggestedSwapSizeB = swapSuggestion( availableSpaceB, o.swap ); + // Space required by this installation is what the distro claims is needed + // (via global configuration) plus the swap size plus a fudge factor of + // 0.6GiB (this was 2.1GiB up to Calamares 3.2.2). + quint64 requiredSpaceB = o.requiredSpaceB + 600_MiB + suggestedSwapSizeB; + + // If there is enough room for ESP + root + swap, create swap, otherwise don't. + shouldCreateSwap = availableSpaceB > requiredSpaceB; + } + + qint64 lastSectorForRoot = lastUsableSector; + if ( shouldCreateSwap ) + { + lastSectorForRoot -= suggestedSwapSizeB / sectorSize + 1; + } + + core->layoutApply( dev, firstFreeSector, lastSectorForRoot, o.luksFsType, o.luksPassphrase ); + + if ( shouldCreateSwap ) + { + Partition* swapPartition = nullptr; + if ( o.luksPassphrase.isEmpty() ) + { + swapPartition = KPMHelpers::createNewPartition( dev->partitionTable(), + *dev, + PartitionRole( PartitionRole::Primary ), + FileSystem::LinuxSwap, + QStringLiteral( "swap" ), + lastSectorForRoot + 1, + lastUsableSector, + KPM_PARTITION_FLAG( None ) ); + } + else + { + swapPartition = KPMHelpers::createNewEncryptedPartition( dev->partitionTable(), + *dev, + PartitionRole( PartitionRole::Primary ), + FileSystem::LinuxSwap, + QStringLiteral( "swap" ), + lastSectorForRoot + 1, + lastUsableSector, + o.luksFsType, + o.luksPassphrase, + KPM_PARTITION_FLAG( None ) ); + } + PartitionInfo::setFormat( swapPartition, true ); + if ( gs->contains( "swapPartitionName" ) ) + { + swapPartition->setLabel( gs->value( "swapPartitionName" ).toString() ); + } + core->createPartition( dev, swapPartition ); + } + + core->dumpQueue(); +} + +void +doReplacePartition( PartitionCoreModule* core, Device* dev, Partition* partition, Choices::ReplacePartitionOptions o ) +{ + qint64 firstSector, lastSector; + + cDebug() << "doReplacePartition for device" << partition->partitionPath(); + + // Looking up the defaultFsType (which should name a filesystem type) + // will log an error and set the type to Unknown if there's something wrong. + FileSystem::Type type = FileSystem::Unknown; + PartUtils::canonicalFilesystemName( o.defaultFsType, &type ); + core->partitionLayout().setDefaultFsType( type == FileSystem::Unknown ? FileSystem::Ext4 : type ); + + PartitionRole newRoles( partition->roles() ); + if ( partition->roles().has( PartitionRole::Extended ) ) + { + newRoles = PartitionRole( PartitionRole::Primary ); + } + + if ( partition->roles().has( PartitionRole::Unallocated ) ) + { + newRoles = PartitionRole( PartitionRole::Primary ); + cWarning() << "selected partition is free space"; + if ( partition->parent() ) + { + Partition* parent = dynamic_cast< Partition* >( partition->parent() ); + if ( parent && parent->roles().has( PartitionRole::Extended ) ) + { + newRoles = PartitionRole( PartitionRole::Logical ); + } + } + } + + // Save the first and last sector values as the partition will be deleted + firstSector = partition->firstSector(); + lastSector = partition->lastSector(); + if ( !partition->roles().has( PartitionRole::Unallocated ) ) + { + core->deletePartition( dev, partition ); + } + + core->layoutApply( dev, firstSector, lastSector, o.luksFsType, o.luksPassphrase ); + + core->dumpQueue(); +} + +} // namespace PartitionActions diff --git a/calamares/src/modules/partition/core/PartitionActions.h b/calamares/src/modules/partition/core/PartitionActions.h new file mode 100644 index 0000000..24969bb --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionActions.h @@ -0,0 +1,95 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONACTIONS_H +#define PARTITIONACTIONS_H + +#include "Config.h" + +#include +#include + +class PartitionCoreModule; +class Device; +class Partition; + +namespace PartitionActions +{ +/** @brief Namespace for enums + * + * This namespace houses non-class enums..... + */ +namespace Choices +{ +struct ReplacePartitionOptions +{ + QString defaultPartitionTableType; // e.g. "gpt" or "msdos" + QString defaultFsType; // e.g. "ext4" or "btrfs" + Config::LuksGeneration luksFsType = Config::LuksGeneration::Luks1; // optional ("luks", "luks2") + QString luksPassphrase; // optional + + ReplacePartitionOptions( const QString& pt, + const QString& fs, + Config::LuksGeneration luksFs, + const QString& passphrase ) + : defaultPartitionTableType( pt ) + , defaultFsType( fs ) + , luksFsType( luksFs ) + , luksPassphrase( passphrase ) + { + } +}; + +struct AutoPartitionOptions : ReplacePartitionOptions +{ + QString efiPartitionMountPoint; // optional, e.g. "/boot" + quint64 requiredSpaceB; // estimated required space for root partition + Config::SwapChoice swap; + + AutoPartitionOptions( const QString& pt, + const QString& fs, + Config::LuksGeneration luksFs, + const QString& passphrase, + const QString& efi, + qint64 requiredBytes, + Config::SwapChoice s ) + : ReplacePartitionOptions( pt, fs, luksFs, passphrase ) + , efiPartitionMountPoint( efi ) + , requiredSpaceB( requiredBytes > 0 ? quint64( requiredBytes ) : 0U ) + , swap( s ) + { + } +}; + +} // namespace Choices + +/** + * @brief doAutopartition sets up an autopartitioning operation on the given Device. + * @param core a pointer to the PartitionCoreModule instance. + * @param dev the device to wipe. + * @param options settings for autopartitioning. + */ +void doAutopartition( PartitionCoreModule* core, Device* dev, Choices::AutoPartitionOptions options ); + +/** + * @brief doReplacePartition sets up replace-partitioning with the given partition. + * @param core a pointer to the PartitionCoreModule instance. + * @param dev a pointer to the Device on which to replace a partition. + * @param partition a pointer to the Partition to be replaced. + * @param options settings for partitioning (not all fields apply) + * + * @note this function also takes care of requesting PCM to delete the partition. + */ +void doReplacePartition( PartitionCoreModule* core, + Device* dev, + Partition* partition, + Choices::ReplacePartitionOptions options ); +} // namespace PartitionActions + +#endif // PARTITIONACTIONS_H diff --git a/calamares/src/modules/partition/core/PartitionCoreModule.cpp b/calamares/src/modules/partition/core/PartitionCoreModule.cpp new file mode 100644 index 0000000..d92c66f --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionCoreModule.cpp @@ -0,0 +1,1223 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2018 Caio Carvalho + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "core/PartitionCoreModule.h" + +#include "core/BootLoaderModel.h" +#include "core/ColorUtils.h" +#include "core/DeviceList.h" +#include "core/DeviceModel.h" +#include "core/KPMHelpers.h" +#include "core/PartUtils.h" +#include "core/PartitionInfo.h" +#include "core/PartitionModel.h" +#include "jobs/AutoMountManagementJob.h" +#include "jobs/ChangeFilesystemLabelJob.h" +#include "jobs/ClearMountsJob.h" +#include "jobs/ClearTempMountsJob.h" +#include "jobs/CreatePartitionJob.h" +#include "jobs/CreatePartitionTableJob.h" +#include "jobs/CreateVolumeGroupJob.h" +#include "jobs/DeactivateVolumeGroupJob.h" +#include "jobs/DeletePartitionJob.h" +#include "jobs/FillGlobalStorageJob.h" +#include "jobs/FormatPartitionJob.h" +#include "jobs/RemoveVolumeGroupJob.h" +#include "jobs/ResizePartitionJob.h" +#include "jobs/ResizeVolumeGroupJob.h" +#include "jobs/SetPartitionFlagsJob.h" + +#ifdef DEBUG_PARTITION_BAIL_OUT +#include "JobExample.h" +#endif +#include "partition/PartitionIterator.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" +#include "utils/Traits.h" +#include "utils/Variant.h" + +// KPMcore +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Qt +#include +#include +#include +#include + +using Calamares::Partition::isPartitionFreeSpace; +using Calamares::Partition::isPartitionNew; +using Calamares::Partition::PartitionIterator; + +PartitionCoreModule::RefreshHelper::RefreshHelper( PartitionCoreModule* module ) + : m_module( module ) +{ +} + +PartitionCoreModule::RefreshHelper::~RefreshHelper() +{ + m_module->refreshAfterModelChange(); +} + +class OperationHelper +{ +public: + OperationHelper( PartitionModel* model, PartitionCoreModule* core ) + : m_coreHelper( core ) + , m_modelHelper( model ) + { + } + + OperationHelper( const OperationHelper& ) = delete; + OperationHelper& operator=( const OperationHelper& ) = delete; + +private: + // Keep these in order: first the model needs to finish, + // then refresh is called. Remember that destructors are + // called in *reverse* order of declaration in this class. + PartitionCoreModule::RefreshHelper m_coreHelper; + PartitionModel::ResetHelper m_modelHelper; +}; + + +//- DeviceInfo --------------------------------------------- +// Some jobs have an updatePreview some don't +DECLARE_HAS_METHOD( updatePreview ) + +template < typename Job > +void +updatePreview( Job* job, const std::true_type& ) +{ + job->updatePreview(); +} + +template < typename Job > +void +updatePreview( Job*, const std::false_type& ) +{ +} + +template < typename Job > +void +updatePreview( Job* job ) +{ + updatePreview( job, has_updatePreview< Job > {} ); +} + +/** + * Owns the Device, PartitionModel and the jobs + */ +struct PartitionCoreModule::DeviceInfo +{ + DeviceInfo( Device* ); + ~DeviceInfo(); + QScopedPointer< Device > device; + QScopedPointer< PartitionModel > partitionModel; + const QScopedPointer< Device > immutableDevice; + + // To check if LVM VGs are deactivated + bool isAvailable; + + void forgetChanges(); + bool isDirty() const; + + const Calamares::JobList& jobs() const { return m_jobs; } + + /** @brief Take the jobs of the given type that apply to @p partition + * + * Returns a job pointer to the job that has just been removed. + */ + template < typename Job > + Calamares::job_ptr takeJob( Partition* partition ) + { + for ( auto it = m_jobs.begin(); it != m_jobs.end(); ) + { + Job* job = qobject_cast< Job* >( it->data() ); + if ( job && job->partition() == partition ) + { + Calamares::job_ptr p = *it; + it = m_jobs.erase( it ); + return p; + } + else + { + ++it; + } + } + + return Calamares::job_ptr( nullptr ); + } + + /** @brief Take the jobs of any type that apply to @p partition */ + void takeJobs( Partition* partition ) + { + for ( auto it = m_jobs.begin(); it != m_jobs.end(); ) + { + PartitionJob* job = qobject_cast< PartitionJob* >( it->data() ); + if ( job && job->partition() == partition ) + { + it = m_jobs.erase( it ); + } + else + { + ++it; + } + } + } + + /** @brief Add a job of given type to the job list + */ + template < typename Job, typename... Args > + Calamares::Job* makeJob( Args... a ) + { + auto* job = new Job( device.get(), a... ); + updatePreview( job ); + m_jobs << Calamares::job_ptr( job ); + return job; + } + +private: + Calamares::JobList m_jobs; +}; + + +PartitionCoreModule::DeviceInfo::DeviceInfo( Device* _device ) + : device( _device ) + , partitionModel( new PartitionModel ) + , immutableDevice( new Device( *_device ) ) + , isAvailable( true ) +{ +} + +PartitionCoreModule::DeviceInfo::~DeviceInfo() {} + + +void +PartitionCoreModule::DeviceInfo::forgetChanges() +{ + m_jobs.clear(); + for ( auto it = PartitionIterator::begin( device.data() ); it != PartitionIterator::end( device.data() ); ++it ) + { + PartitionInfo::reset( *it ); + } + partitionModel->revert(); +} + + +bool +PartitionCoreModule::DeviceInfo::isDirty() const +{ + if ( !m_jobs.isEmpty() ) + { + return true; + } + + for ( auto it = PartitionIterator::begin( device.data() ); it != PartitionIterator::end( device.data() ); ++it ) + { + if ( PartitionInfo::isDirty( *it ) ) + { + return true; + } + } + + return false; +} + +//- PartitionCoreModule ------------------------------------ +PartitionCoreModule::PartitionCoreModule( QObject* parent ) + : QObject( parent ) + , m_deviceModel( new DeviceModel( this ) ) + , m_bootLoaderModel( new BootLoaderModel( this ) ) +{ + if ( !m_kpmcore ) + { + qFatal( "Failed to initialize KPMcore backend" ); + } +} + + +void +PartitionCoreModule::init() +{ + QMutexLocker locker( &m_revertMutex ); + doInit(); +} + + +void +PartitionCoreModule::doInit() +{ + FileSystemFactory::init(); + + using DeviceList = QList< Device* >; + DeviceList devices = PartUtils::getDevices( PartUtils::DeviceType::WritableOnly ); + + cDebug() << "LIST OF DETECTED DEVICES:"; + cDebug() << Logger::SubEntry << "node\tcapacity\tname\tprettyName"; + for ( auto device : devices ) + { + if ( device ) + { + // Gives ownership of the Device* to the DeviceInfo object + auto deviceInfo = new DeviceInfo( device ); + m_deviceInfos << deviceInfo; + cDebug() << Logger::SubEntry << device->deviceNode() << device->capacity() + << Logger::RedactedName( "DevName", device->name() ) + << Logger::RedactedName( "DevNamePretty", device->prettyName() ); + } + else + { + cDebug() << Logger::SubEntry << "(skipped null device)"; + } + } + cDebug() << Logger::SubEntry << devices.count() << "devices detected."; + m_deviceModel->init( devices ); + + // The following PartUtils::runOsprober call in turn calls PartUtils::canBeResized, + // which relies on a working DeviceModel. + m_osproberLines = PartUtils::runOsprober( this->deviceModel() ); + + // We perform a best effort of filling out filesystem UUIDs in m_osproberLines + // because we will need them later on in PartitionModel if partition paths + // change. + // It is a known fact that /dev/sda1-style device paths aren't persistent + // across reboots (and this doesn't affect us), but partition numbers can also + // change at runtime against our will just for shits and giggles. + // But why would that ever happen? What system could possibly be so poorly + // designed that it requires a partition path rearrangement at runtime? + // Logical partitions on an MSDOS disklabel of course. + // See DeletePartitionJob::updatePreview. + for ( auto deviceInfo : m_deviceInfos ) + { + for ( auto it = PartitionIterator::begin( deviceInfo->device.data() ); + it != PartitionIterator::end( deviceInfo->device.data() ); + ++it ) + { + Partition* partition = *it; + for ( auto jt = m_osproberLines.begin(); jt != m_osproberLines.end(); ++jt ) + { + if ( jt->path == partition->partitionPath() + && partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() ) + { + jt->uuid = partition->fileSystem().uuid(); + } + } + } + } + + for ( auto deviceInfo : m_deviceInfos ) + { + deviceInfo->partitionModel->init( deviceInfo->device.data(), m_osproberLines ); + } + + DeviceList bootLoaderDevices; + + for ( DeviceList::Iterator it = devices.begin(); it != devices.end(); ++it ) + { + if ( ( *it )->type() != Device::Type::Disk_Device ) + { + cDebug() << "Ignoring device that is not Disk_Device to bootLoaderDevices list."; + continue; + } + else + { + bootLoaderDevices.append( *it ); + } + } + + m_bootLoaderModel->init( bootLoaderDevices ); + + scanForLVMPVs(); + + //FIXME: this should be removed in favor of + // proper KPM support for EFI + if ( PartUtils::isEfiSystem() ) + { + scanForEfiSystemPartitions(); + } +} + +PartitionCoreModule::~PartitionCoreModule() +{ + qDeleteAll( m_deviceInfos ); +} + +DeviceModel* +PartitionCoreModule::deviceModel() const +{ + return m_deviceModel; +} + +BootLoaderModel* +PartitionCoreModule::bootLoaderModel() const +{ + return m_bootLoaderModel; +} + +PartitionModel* +PartitionCoreModule::partitionModelForDevice( const Device* device ) const +{ + DeviceInfo* info = infoForDevice( device ); + Q_ASSERT( info ); + return info->partitionModel.data(); +} + + +Device* +PartitionCoreModule::immutableDeviceCopy( const Device* device ) +{ + Q_ASSERT( device ); + DeviceInfo* info = infoForDevice( device ); + if ( !info ) + { + return nullptr; + } + + return info->immutableDevice.data(); +} + + +void +PartitionCoreModule::createPartitionTable( Device* device, PartitionTable::TableType type ) +{ + auto* deviceInfo = infoForDevice( device ); + if ( deviceInfo ) + { + // Creating a partition table wipes all the disk, so there is no need to + // keep previous changes + deviceInfo->forgetChanges(); + + OperationHelper helper( partitionModelForDevice( device ), this ); + deviceInfo->makeJob< CreatePartitionTableJob >( type ); + } +} + +void +PartitionCoreModule::createPartition( Device* device, Partition* partition, PartitionTable::Flags flags ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + + OperationHelper helper( partitionModelForDevice( device ), this ); + deviceInfo->makeJob< CreatePartitionJob >( partition ); + + if ( flags != KPM_PARTITION_FLAG( None ) ) + { + deviceInfo->makeJob< SetPartFlagsJob >( partition, flags ); + PartitionInfo::setFlags( partition, flags ); + } +} + +void +PartitionCoreModule::createVolumeGroup( QString& vgName, QVector< const Partition* > pvList, qint32 peSize ) +{ + // Appending '_' character in case of repeated VG name + while ( hasVGwithThisName( vgName ) ) + { + vgName.append( '_' ); + } + + LvmDevice* device = new LvmDevice( vgName ); + for ( const Partition* p : pvList ) + { + device->physicalVolumes() << p; + } + + DeviceInfo* deviceInfo = new DeviceInfo( device ); + deviceInfo->partitionModel->init( device, osproberEntries() ); + m_deviceModel->addDevice( device ); + m_deviceInfos << deviceInfo; + + deviceInfo->makeJob< CreateVolumeGroupJob >( vgName, pvList, peSize ); + refreshAfterModelChange(); +} + +void +PartitionCoreModule::resizeVolumeGroup( LvmDevice* device, QVector< const Partition* >& pvList ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + deviceInfo->makeJob< ResizeVolumeGroupJob >( device, pvList ); + refreshAfterModelChange(); +} + +void +PartitionCoreModule::deactivateVolumeGroup( LvmDevice* device ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + + deviceInfo->isAvailable = false; + + // TODO: this leaks + DeactivateVolumeGroupJob* job = new DeactivateVolumeGroupJob( device ); + + // DeactivateVolumeGroupJob needs to be immediately called + job->exec(); + + refreshAfterModelChange(); +} + +void +PartitionCoreModule::removeVolumeGroup( LvmDevice* device ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + deviceInfo->makeJob< RemoveVolumeGroupJob >( device ); + refreshAfterModelChange(); +} + +void +PartitionCoreModule::deletePartition( Device* device, Partition* partition ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + + OperationHelper helper( partitionModelForDevice( device ), this ); + + if ( partition->roles().has( PartitionRole::Extended ) ) + { + // Delete all logical partitions first + // I am not sure if we can iterate on Partition::children() while + // deleting them, so let's play it safe and keep our own list. + QList< Partition* > lst; + for ( auto childPartition : partition->children() ) + { + if ( !isPartitionFreeSpace( childPartition ) ) + { + lst << childPartition; + } + } + + for ( auto childPartition : lst ) + { + deletePartition( device, childPartition ); + } + } + + if ( partition->state() == KPM_PARTITION_STATE( New ) ) + { + // Take all the SetPartFlagsJob from the list and delete them + do + { + auto job_ptr = deviceInfo->takeJob< SetPartFlagsJob >( partition ); + if ( job_ptr.data() ) + { + continue; + } + } while ( false ); + + + // Find matching CreatePartitionJob + auto job_ptr = deviceInfo->takeJob< CreatePartitionJob >( partition ); + if ( !job_ptr.data() ) + { + cDebug() << "Failed to find a CreatePartitionJob matching the partition to remove"; + return; + } + // Remove it + if ( !partition->parent()->remove( partition ) ) + { + cDebug() << "Failed to remove partition from preview"; + return; + } + + device->partitionTable()->updateUnallocated( *device ); + // The partition is no longer referenced by either a job or the device + // partition list, so we have to delete it + delete partition; + } + else + { + // Remove any PartitionJob on this partition + do + { + auto job_ptr = deviceInfo->takeJob< PartitionJob >( partition ); + if ( job_ptr.data() ) + { + continue; + } + } while ( false ); + + deviceInfo->makeJob< DeletePartitionJob >( partition ); + } +} + +void +PartitionCoreModule::formatPartition( Device* device, Partition* partition ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + OperationHelper helper( partitionModelForDevice( device ), this ); + deviceInfo->makeJob< FormatPartitionJob >( partition ); +} + +void +PartitionCoreModule::setFilesystemLabel( Device* device, Partition* partition, const QString& newLabel ) +{ + if ( newLabel == PartitionInfo::label( partition ) ) + { + return; + } + + auto deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + + OperationHelper helper( partitionModelForDevice( device ), this ); + PartitionInfo::setLabel( partition, newLabel ); + deviceInfo->takeJob< ChangeFilesystemLabelJob >( partition ); + deviceInfo->makeJob< ChangeFilesystemLabelJob >( partition, newLabel ); +} + +void +PartitionCoreModule::resizePartition( Device* device, Partition* partition, qint64 first, qint64 last ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + OperationHelper helper( partitionModelForDevice( device ), this ); + deviceInfo->makeJob< ResizePartitionJob >( partition, first, last ); +} + +void +PartitionCoreModule::setPartitionFlags( Device* device, Partition* partition, PartitionTable::Flags flags ) +{ + auto* deviceInfo = infoForDevice( device ); + Q_ASSERT( deviceInfo ); + OperationHelper( partitionModelForDevice( device ), this ); + deviceInfo->makeJob< SetPartFlagsJob >( partition, flags ); + PartitionInfo::setFlags( partition, flags ); +} + +STATICTEST QStringList +findEssentialLVs( const QList< PartitionCoreModule::DeviceInfo* >& infos ) +{ + QStringList essentialLV; + cDebug() << "Checking LVM use on" << infos.count() << "devices"; + for ( const auto* info : infos ) + { + if ( info->device->type() != Device::Type::LVM_Device ) + { + continue; + } + + for ( const auto& j : std::as_const( info->jobs() ) ) + { + FormatPartitionJob* format = dynamic_cast< FormatPartitionJob* >( j.data() ); + if ( format ) + { + // device->deviceNode() is /dev/ + // partition()->partitionPath() is /dev// + const auto* partition = format->partition(); + const QString partPath = partition->partitionPath(); + const QString devicePath = info->device->deviceNode() + '/'; + const bool isLvm = partition->roles().has( PartitionRole::Lvm_Lv ); + if ( isLvm && partPath.startsWith( devicePath ) ) + { + cDebug() << Logger::SubEntry << partPath + << "is an essential LV filesystem=" << partition->fileSystem().type(); + QString lvName = partPath.right( partPath.length() - devicePath.length() ); + essentialLV.append( info->device->name() + '-' + lvName ); + } + } + } + } + return essentialLV; +} + +Calamares::JobList +PartitionCoreModule::jobs( const Config* config ) const +{ + Calamares::JobList lst; + QList< Device* > devices; + +#ifdef DEBUG_PARTITION_UNSAFE +#ifdef DEBUG_PARTITION_BAIL_OUT + cDebug() << "Unsafe partitioning is enabled."; + cDebug() << Logger::SubEntry << "it has been lamed, and will fail."; + lst << Calamares::job_ptr( new Calamares::FailJob( QStringLiteral( "Partition" ) ) ); +#else + cWarning() << "Unsafe partitioning is enabled."; + cWarning() << Logger::SubEntry << "the unsafe actions will be executed."; +#endif +#endif + + // The automountControl job goes in the list twice: the first + // time it runs, it disables automount and remembers the old setting + // for automount; the second time it restores that old setting. + Calamares::job_ptr automountControl( new AutoMountManagementJob( true /* disable automount */ ) ); + lst << automountControl; + lst << Calamares::job_ptr( new ClearTempMountsJob() ); + +#ifdef DEBUG_PARTITION_SKIP + cWarning() << "Partitioning actions are skipped."; +#else + const QStringList essentialMounts = findEssentialLVs( m_deviceInfos ) + config->essentialMounts(); + + for ( const auto* info : m_deviceInfos ) + { + if ( info->isDirty() ) + { + auto* job = new ClearMountsJob( info->device.data() ); + job->setMapperExceptions( essentialMounts ); + lst << Calamares::job_ptr( job ); + } + } +#endif + + for ( const auto* info : m_deviceInfos ) + { +#ifdef DEBUG_PARTITION_SKIP + cWarning() << Logger::SubEntry << "Skipping jobs for" << info->device.data()->deviceNode(); +#else + lst << info->jobs(); +#endif + devices << info->device.data(); + } + lst << Calamares::job_ptr( new FillGlobalStorageJob( config, devices, m_bootLoaderInstallPath ) ); + lst << automountControl; + + return lst; +} + +bool +PartitionCoreModule::hasRootMountPoint() const +{ + return m_hasRootMountPoint; +} + +QList< Partition* > +PartitionCoreModule::efiSystemPartitions() const +{ + return m_efiSystemPartitions; +} + +QVector< const Partition* > +PartitionCoreModule::lvmPVs() const +{ + return m_lvmPVs; +} + +bool +PartitionCoreModule::hasVGwithThisName( const QString& name ) const +{ + auto condition = [ name ]( DeviceInfo* d ) + { return dynamic_cast< LvmDevice* >( d->device.data() ) && d->device.data()->name() == name; }; + + return std::find_if( m_deviceInfos.begin(), m_deviceInfos.end(), condition ) != m_deviceInfos.end(); +} + +bool +PartitionCoreModule::isInVG( const Partition* partition ) const +{ + auto condition = [ partition ]( DeviceInfo* d ) + { + LvmDevice* vg = dynamic_cast< LvmDevice* >( d->device.data() ); + return vg && vg->physicalVolumes().contains( partition ); + }; + + return std::find_if( m_deviceInfos.begin(), m_deviceInfos.end(), condition ) != m_deviceInfos.end(); +} + +void +PartitionCoreModule::dumpQueue() const +{ + cDebug() << "# Queue:"; + for ( auto info : m_deviceInfos ) + { + cDebug() << Logger::SubEntry << "## Device:" << info->device->deviceNode(); + for ( const auto& job : info->jobs() ) + { + cDebug() << Logger::SubEntry << "-" << job->metaObject()->className(); + } + } +} + + +const OsproberEntryList +PartitionCoreModule::osproberEntries() const +{ + return m_osproberLines; +} + +void +PartitionCoreModule::refreshPartition( Device* device, Partition* ) +{ + // Keep it simple for now: reset the model. This can be improved to cause + // the model to Q_EMIT dataChanged() for the affected row instead, avoiding + // the loss of the current selection. + auto model = partitionModelForDevice( device ); + Q_ASSERT( model ); + OperationHelper helper( model, this ); +} + +void +PartitionCoreModule::refreshAfterModelChange() +{ + updateHasRootMountPoint(); + updateIsDirty(); + m_bootLoaderModel->update(); + + scanForLVMPVs(); + + //FIXME: this should be removed in favor of + // proper KPM support for EFI + if ( PartUtils::isEfiSystem() ) + { + scanForEfiSystemPartitions(); + } +} + +void +PartitionCoreModule::updateHasRootMountPoint() +{ + bool oldValue = m_hasRootMountPoint; + m_hasRootMountPoint = findPartitionByMountPoint( "/" ); + + if ( oldValue != m_hasRootMountPoint ) + { + hasRootMountPointChanged( m_hasRootMountPoint ); + } +} + +void +PartitionCoreModule::updateIsDirty() +{ + bool oldValue = m_isDirty; + m_isDirty = false; + for ( auto info : m_deviceInfos ) + { + if ( info->isDirty() ) + { + m_isDirty = true; + break; + } + } + if ( oldValue != m_isDirty ) + { + isDirtyChanged( m_isDirty ); + } +} + +void +PartitionCoreModule::scanForEfiSystemPartitions() +{ + const bool wasEmpty = m_efiSystemPartitions.isEmpty(); + + m_efiSystemPartitions.clear(); + + QList< Device* > devices; + for ( int row = 0; row < deviceModel()->rowCount(); ++row ) + { + Device* device = deviceModel()->deviceForIndex( deviceModel()->index( row ) ); + devices.append( device ); + } + + QList< Partition* > efiSystemPartitions = Calamares::Partition::findPartitions( devices, PartUtils::isEfiBootable ); + + if ( efiSystemPartitions.isEmpty() ) + { + cWarning() << "system is EFI but no EFI system partitions found."; + } + else if ( wasEmpty ) + { + // But it isn't empty anymore, so whatever problem has been solved + cDebug() << "system is EFI and new EFI system partition has been found."; + } + + m_efiSystemPartitions = efiSystemPartitions; +} + +void +PartitionCoreModule::scanForLVMPVs() +{ + m_lvmPVs.clear(); + + QList< Device* > physicalDevices; + QList< LvmDevice* > vgDevices; + + for ( DeviceInfo* deviceInfo : m_deviceInfos ) + { + if ( deviceInfo->device.data()->type() == Device::Type::Disk_Device ) + { + physicalDevices << deviceInfo->device.data(); + } + else if ( deviceInfo->device.data()->type() == Device::Type::LVM_Device ) + { + LvmDevice* device = dynamic_cast< LvmDevice* >( deviceInfo->device.data() ); + + // Restoring physical volume list + device->physicalVolumes().clear(); + + vgDevices << device; + } + } + + VolumeManagerDevice::scanDevices( physicalDevices ); + for ( auto p : LVM::pvList::list() ) + { + m_lvmPVs << p.partition().data(); + + for ( LvmDevice* device : vgDevices ) + { + if ( p.vgName() == device->name() ) + { + // Adding scanned VG to PV list + device->physicalVolumes() << p.partition(); + break; + } + } + } + + for ( DeviceInfo* d : m_deviceInfos ) + { + for ( const auto& job : d->jobs() ) + { + // Including new LVM PVs + CreatePartitionJob* partJob = dynamic_cast< CreatePartitionJob* >( job.data() ); + if ( partJob ) + { + Partition* p = partJob->partition(); + + if ( p->fileSystem().type() == FileSystem::Type::Lvm2_PV ) + { + m_lvmPVs << p; + } + else if ( p->fileSystem().type() == FileSystem::Type::Luks ) + { + // Encrypted LVM PVs + FileSystem* innerFS = static_cast< const FS::luks* >( &p->fileSystem() )->innerFS(); + + if ( innerFS && innerFS->type() == FileSystem::Type::Lvm2_PV ) + { + m_lvmPVs << p; + } + } + else if ( p->fileSystem().type() == FileSystem::Type::Luks2 ) + { + // Encrypted LVM PVs + FileSystem* innerFS = static_cast< const FS::luks* >( &p->fileSystem() )->innerFS(); + + if ( innerFS && innerFS->type() == FileSystem::Type::Lvm2_PV ) + { + m_lvmPVs << p; + } + } + } + } + } +} + +PartitionCoreModule::DeviceInfo* +PartitionCoreModule::infoForDevice( const Device* device ) const +{ + for ( auto it = m_deviceInfos.constBegin(); it != m_deviceInfos.constEnd(); ++it ) + { + if ( ( *it )->device.data() == device ) + { + return *it; + } + if ( ( *it )->immutableDevice.data() == device ) + { + return *it; + } + } + return nullptr; +} + +Partition* +PartitionCoreModule::findPartitionByMountPoint( const QString& mountPoint ) const +{ + for ( auto deviceInfo : m_deviceInfos ) + { + Device* device = deviceInfo->device.data(); + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + if ( PartitionInfo::mountPoint( *it ) == mountPoint ) + { + return *it; + } + } + } + return nullptr; +} + +void +PartitionCoreModule::setBootLoaderInstallPath( const QString& path ) +{ + cDebug() << "PCM::setBootLoaderInstallPath" << path; + m_bootLoaderInstallPath = path; +} + +static void +applyDefaultLabel( Partition* p, bool ( *predicate )( const Partition* ), const QString& label ) +{ + if ( p->label().isEmpty() && predicate( p ) ) + { + p->setLabel( label ); + } +} + +void +PartitionCoreModule::layoutApply( Device* dev, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + QString luksPassphrase, + PartitionNode* parent, + const PartitionRole& role ) +{ + const bool isEfi = PartUtils::isEfiSystem(); + QList< Partition* > partList + = m_partLayout.createPartitions( dev, firstSector, lastSector, luksFsType, luksPassphrase, parent, role ); + + // Partition::mountPoint() tells us where it is mounted **now**, while + // PartitionInfo::mountPoint() says where it will be mounted in the target system. + // .. the latter is more interesting. + // + // If we have a separate /boot, mark that one as bootable, + // otherwise mark the root / as bootable. + // + // If the layout hasn't applied a label to the partition, + // apply a default label (to boot and root, at least). + const auto is_boot = []( const Partition* p ) -> bool + { + const QString boot = QStringLiteral( "/boot" ); + return PartitionInfo::mountPoint( p ) == boot || p->mountPoint() == boot; + }; + const auto is_root = []( const Partition* p ) -> bool + { + const QString root = QStringLiteral( "/" ); + return PartitionInfo::mountPoint( p ) == root || p->mountPoint() == root; + }; + + const bool separate_boot_partition + = std::find_if( partList.constBegin(), partList.constEnd(), is_boot ) != partList.constEnd(); + for ( Partition* part : partList ) + { + applyDefaultLabel( part, is_root, QStringLiteral( "root" ) ); + applyDefaultLabel( part, is_boot, QStringLiteral( "boot" ) ); + if ( ( separate_boot_partition && is_boot( part ) ) || ( !separate_boot_partition && is_root( part ) ) ) + { + createPartition( + dev, part, part->activeFlags() | ( isEfi ? KPM_PARTITION_FLAG( None ) : KPM_PARTITION_FLAG( Boot ) ) ); + } + else + { + createPartition( dev, part ); + } + } +} + +void +PartitionCoreModule::layoutApply( Device* dev, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + QString luksPassphrase ) +{ + layoutApply( dev, + firstSector, + lastSector, + luksFsType, + luksPassphrase, + dev->partitionTable(), + PartitionRole( PartitionRole::Primary ) ); +} + +void +PartitionCoreModule::revert() +{ + QMutexLocker locker( &m_revertMutex ); + qDeleteAll( m_deviceInfos ); + m_deviceInfos.clear(); + doInit(); + updateIsDirty(); + Q_EMIT reverted(); +} + + +void +PartitionCoreModule::revertAllDevices() +{ + for ( auto it = m_deviceInfos.begin(); it != m_deviceInfos.end(); ) + { + // In new VGs device info, there will be always a CreateVolumeGroupJob as the first job in jobs list + if ( dynamic_cast< LvmDevice* >( ( *it )->device.data() ) ) + { + ( *it )->isAvailable = true; + + if ( !( *it )->jobs().empty() ) + { + CreateVolumeGroupJob* vgJob = dynamic_cast< CreateVolumeGroupJob* >( ( *it )->jobs().first().data() ); + + if ( vgJob ) + { + vgJob->undoPreview(); + + ( *it )->forgetChanges(); + + m_deviceModel->removeDevice( ( *it )->device.data() ); + + it = m_deviceInfos.erase( it ); + + continue; + } + } + } + + revertDevice( ( *it )->device.data(), false ); + ++it; + } + + refreshAfterModelChange(); +} + + +void +PartitionCoreModule::revertDevice( Device* dev, bool individualRevert ) +{ + QMutexLocker locker( &m_revertMutex ); + DeviceInfo* devInfo = infoForDevice( dev ); + + if ( !devInfo ) + { + return; + } + devInfo->forgetChanges(); + CoreBackend* backend = CoreBackendManager::self()->backend(); + Device* newDev = backend->scanDevice( devInfo->device->deviceNode() ); + devInfo->device.reset( newDev ); + devInfo->partitionModel->init( newDev, m_osproberLines ); + + m_deviceModel->swapDevice( dev, newDev ); + + QList< Device* > devices; + for ( DeviceInfo* const info : m_deviceInfos ) + { + if ( info && !info->device.isNull() && info->device->type() == Device::Type::Disk_Device ) + { + devices.append( info->device.data() ); + } + } + + m_bootLoaderModel->init( devices ); + + if ( individualRevert ) + { + refreshAfterModelChange(); + } + Q_EMIT deviceReverted( newDev ); +} + + +void +PartitionCoreModule::asyncRevertDevice( Device* dev, std::function< void() > callback ) +{ + QFutureWatcher< void >* watcher = new QFutureWatcher< void >(); + connect( watcher, + &QFutureWatcher< void >::finished, + this, + [ watcher, callback ] + { + callback(); + watcher->deleteLater(); + } ); + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + QFuture< void > future = QtConcurrent::run( this, &PartitionCoreModule::revertDevice, dev, true ); +#else + QFuture< void > future = QtConcurrent::run( &PartitionCoreModule::revertDevice, this, dev, true ); +#endif + watcher->setFuture( future ); +} + + +void +PartitionCoreModule::clearJobs() +{ + foreach ( DeviceInfo* deviceInfo, m_deviceInfos ) + { + deviceInfo->forgetChanges(); + } + updateIsDirty(); +} + +void +PartitionCoreModule::clearJobs( Device* device, Partition* partition ) +{ + DeviceInfo* devInfo = infoForDevice( device ); + + if ( devInfo ) + { + devInfo->takeJobs( partition ); + } +} + + +bool +PartitionCoreModule::isDirty() +{ + return m_isDirty; +} + +bool +PartitionCoreModule::isVGdeactivated( LvmDevice* device ) +{ + for ( DeviceInfo* deviceInfo : m_deviceInfos ) + { + if ( device == deviceInfo->device.data() && !deviceInfo->isAvailable ) + { + return true; + } + } + + return false; +} + +QList< PartitionCoreModule::SummaryInfo > +PartitionCoreModule::createSummaryInfo() const +{ + QList< SummaryInfo > lst; + for ( auto deviceInfo : m_deviceInfos ) + { + if ( !deviceInfo->isDirty() ) + { + continue; + } + SummaryInfo summaryInfo; + summaryInfo.deviceName = deviceInfo->device->name(); + summaryInfo.deviceNode = deviceInfo->device->deviceNode(); + + Device* deviceBefore = deviceInfo->immutableDevice.data(); + summaryInfo.partitionModelBefore = new PartitionModel; + summaryInfo.partitionModelBefore->init( deviceBefore, m_osproberLines ); + // Make deviceBefore a child of partitionModelBefore so that it is not + // leaked (as long as partitionModelBefore is deleted) + deviceBefore->setParent( summaryInfo.partitionModelBefore ); + + summaryInfo.partitionModelAfter = new PartitionModel; + summaryInfo.partitionModelAfter->init( deviceInfo->device.data(), m_osproberLines ); + + lst << summaryInfo; + } + return lst; +} diff --git a/calamares/src/modules/partition/core/PartitionCoreModule.h b/calamares/src/modules/partition/core/PartitionCoreModule.h new file mode 100644 index 0000000..08b92ab --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionCoreModule.h @@ -0,0 +1,284 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONCOREMODULE_H +#define PARTITIONCOREMODULE_H + +#include "Config.h" +#include "core/KPMHelpers.h" +#include "core/PartitionLayout.h" +#include "core/PartitionModel.h" +#include "core/DirFSRestrictLayout.h" +#include "jobs/PartitionJob.h" + +#include "Job.h" +#include "partition/KPMManager.h" + +// KPMcore +#include +#include + +// Qt +#include +#include +#include + +#include + +class BootLoaderModel; +class Config; +class CreatePartitionJob; +class Device; +class DeviceModel; +class FileSystem; +class Partition; + +class QStandardItemModel; + +/** + * The core of the module. + * + * It has two responsibilities: + * - Listing the devices and partitions, creating Qt models for them. + * - Creating jobs for any changes requested by the user interface. + */ +class PartitionCoreModule : public QObject +{ + Q_OBJECT +public: + /** + * This helper class calls refresh() on the module + * on destruction (nothing else). It is used as + * part of the model-consistency objects, along with + * PartitionModel::ResetHelper. + */ + class RefreshHelper + { + public: + RefreshHelper( PartitionCoreModule* module ); + ~RefreshHelper(); + + RefreshHelper( const RefreshHelper& ) = delete; + RefreshHelper& operator=( const RefreshHelper& ) = delete; + + private: + PartitionCoreModule* m_module; + }; + + /** + * @brief The SummaryInfo struct is a wrapper for PartitionModel instances for + * a given Device. + * Each Device gets a mutable "after" model and an immutable "before" model. + */ + struct SummaryInfo + { + QString deviceName; + QString deviceNode; + PartitionModel* partitionModelBefore; + PartitionModel* partitionModelAfter; + }; + + struct DeviceInfo; + + PartitionCoreModule( QObject* parent = nullptr ); + ~PartitionCoreModule() override; + + /** + * @brief init performs a devices scan and initializes all KPMcore data + * structures. + * This function is thread safe. + */ + void init(); + + /** + * @brief deviceModel returns a model which exposes a list of available + * storage devices. + * @return the device model. + */ + DeviceModel* deviceModel() const; + + /** + * @brief partitionModelForDevice returns the PartitionModel for the given device. + * @param device the device for which to get a model. + * @return a PartitionModel which represents the partitions of a device. + */ + PartitionModel* partitionModelForDevice( const Device* device ) const; + + //HACK: all devices change over time, and together make up the state of the CoreModule. + // However this makes it hard to show the *original* state of a device. + // For each DeviceInfo we keep a second Device object that contains the + // current state of a disk regardless of subsequent changes. + // -- Teo 4/2015 + //FIXME: make this horrible method private. -- Teo 12/2015 + Device* immutableDeviceCopy( const Device* device ); + + /** + * @brief bootLoaderModel returns a model which represents the available boot + * loader locations. + * The single BootLoaderModel instance belongs to the PCM. + * @return the BootLoaderModel. + */ + BootLoaderModel* bootLoaderModel() const; + + void createPartitionTable( Device* device, PartitionTable::TableType type ); + + /** + * @brief Add a job to do the actual partition-creation. + * + * If @p flags is not FlagNone, then the given flags are + * applied to the newly-created partition. + */ + void + createPartition( Device* device, Partition* partition, PartitionTable::Flags flags = KPM_PARTITION_FLAG( None ) ); + + void createVolumeGroup( QString& vgName, QVector< const Partition* > pvList, qint32 peSize ); + + void resizeVolumeGroup( LvmDevice* device, QVector< const Partition* >& pvList ); + + void deactivateVolumeGroup( LvmDevice* device ); + + void removeVolumeGroup( LvmDevice* device ); + + void deletePartition( Device* device, Partition* partition ); + + void formatPartition( Device* device, Partition* partition ); + + void setFilesystemLabel( Device* device, Partition* partition, const QString& newLabel ); + + void resizePartition( Device* device, Partition* partition, qint64 first, qint64 last ); + + void setPartitionFlags( Device* device, Partition* partition, PartitionTable::Flags flags ); + + /// @brief Retrieve the path where the bootloader will be installed + QString bootLoaderInstallPath() const { return m_bootLoaderInstallPath; } + /// @brief Set the path where the bootloader will be installed + void setBootLoaderInstallPath( const QString& path ); + + /** @brief Get the partition layout that will be applied. + * + * Layouts are applied only for erase and replace operations. + */ + PartitionLayout& partitionLayout() { return m_partLayout; } + + /// @brief Get the directory filesystem restriction layout. + DirFSRestrictLayout& dirFSRestrictLayout() { return m_dirFSRestrictLayout; } + + void layoutApply( Device* dev, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + QString luksPassphrase ); + void layoutApply( Device* dev, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + QString luksPassphrase, + PartitionNode* parent, + const PartitionRole& role ); + + /** + * @brief jobs creates and returns a list of jobs which can then apply the changes + * requested by the user. + * @return a list of jobs. + */ + Calamares::JobList jobs( const Config* ) const; + + bool hasRootMountPoint() const; + + QList< Partition* > efiSystemPartitions() const; + + QVector< const Partition* > lvmPVs() const; + + bool hasVGwithThisName( const QString& name ) const; + + bool isInVG( const Partition* partition ) const; + + /** + * @brief findPartitionByMountPoint returns a Partition* for a given mount point. + * @param mountPoint the mount point to find a partition for. + * @return a pointer to a Partition object. + * Note that this function looks for partitions in live devices (the "proposed" + * state), not the immutable copies. Comparisons with Partition* objects that + * refer to immutable Device*s will fail. + */ + Partition* findPartitionByMountPoint( const QString& mountPoint ) const; + + void revert(); // full revert, thread safe, calls doInit + void revertAllDevices(); // convenience function, calls revertDevice + /** @brief rescans a single Device and updates DeviceInfo + * + * When @p individualRevert is true, calls refreshAfterModelChange(), + * used to reduce number of refreshes when calling revertAllDevices(). + */ + void revertDevice( Device* dev, bool individualRevert = true ); + void asyncRevertDevice( Device* dev, std::function< void() > callback ); //like revertDevice, but asynchronous + + void clearJobs(); // only clear jobs, the Device* states are preserved + void clearJobs( Device* device, Partition* partition ); // clears all jobs changing @p partition + + bool isDirty(); // true if there are pending changes, otherwise false + + bool isVGdeactivated( LvmDevice* device ); + + /** + * To be called when a partition has been altered, but only for changes + * which do not affect its size, because changes which affect the partition size + * affect the size of other partitions as well. + */ + void refreshPartition( Device* device, Partition* partition ); + + /** + * Returns a list of SummaryInfo for devices which have pending changes. + * Caller is responsible for deleting the partition models + */ + QList< SummaryInfo > createSummaryInfo() const; + + const OsproberEntryList osproberEntries() const; // os-prober data structure, cached + + void dumpQueue() const; // debug output + +Q_SIGNALS: + void hasRootMountPointChanged( bool value ); + void isDirtyChanged( bool value ); + void reverted(); + void deviceReverted( Device* device ); + +private: + void refreshAfterModelChange(); + + void doInit(); + void updateHasRootMountPoint(); + void updateIsDirty(); + void scanForEfiSystemPartitions(); + void scanForLVMPVs(); + + DeviceInfo* infoForDevice( const Device* ) const; + + Calamares::Partition::KPMManager m_kpmcore; + + QList< DeviceInfo* > m_deviceInfos; + QList< Partition* > m_efiSystemPartitions; + QVector< const Partition* > m_lvmPVs; + + DeviceModel* m_deviceModel; + BootLoaderModel* m_bootLoaderModel; + bool m_hasRootMountPoint = false; + bool m_isDirty = false; + QString m_bootLoaderInstallPath; + PartitionLayout m_partLayout; + DirFSRestrictLayout m_dirFSRestrictLayout; + + OsproberEntryList m_osproberLines; + + QMutex m_revertMutex; +}; + +#endif /* PARTITIONCOREMODULE_H */ diff --git a/calamares/src/modules/partition/core/PartitionInfo.cpp b/calamares/src/modules/partition/core/PartitionInfo.cpp new file mode 100644 index 0000000..708c6f3 --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionInfo.cpp @@ -0,0 +1,118 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "core/PartitionInfo.h" +#include "compat/Variant.h" + +// KPMcore +#include +#include +#include + +// Qt +#include + +namespace PartitionInfo +{ + +static const char MOUNT_POINT_PROPERTY[] = "_calamares_mountPoint"; +static const char FORMAT_PROPERTY[] = "_calamares_format"; +static const char FLAGS_PROPERTY[] = "_calamares_flags"; +static const char LABEL_PROPERTY[] = "_calamares_label"; + +QString +mountPoint( const Partition* partition ) +{ + return partition->property( MOUNT_POINT_PROPERTY ).toString(); +} + +void +setMountPoint( Partition* partition, const QString& value ) +{ + partition->setProperty( MOUNT_POINT_PROPERTY, value ); +} + +bool +format( const Partition* partition ) +{ + return partition->property( FORMAT_PROPERTY ).toBool(); +} + +void +setFormat( Partition* partition, bool value ) +{ + partition->setProperty( FORMAT_PROPERTY, value ); +} + +PartitionTable::Flags +flags( const Partition* partition ) +{ + auto v = partition->property( FLAGS_PROPERTY ); + if ( !v.isValid() ) + { + return partition->activeFlags(); + } + // The underlying type of PartitionTable::Flags can be int or uint + // (see qflags.h) and so setting those flags can create a QVariant + // of those types; we don't just want to check QVariant::canConvert() + // here because that will also accept QByteArray and some other things. + if ( Calamares::typeOf( v ) == Calamares::IntVariantType || Calamares::typeOf( v ) == Calamares::UIntVariantType ) + { + return static_cast< PartitionTable::Flags >( v.toInt() ); + } + return partition->activeFlags(); +} + +void +setFlags( Partition* partition, PartitionTable::Flags f ) +{ + partition->setProperty( FLAGS_PROPERTY, PartitionTable::Flags::Int( f ) ); +} + +QString +label( const Partition* partition ) +{ + auto v = partition->property( LABEL_PROPERTY ); + if ( !v.isValid() ) + { + return partition->fileSystem().label(); + } + return v.toString(); +} + +void +setLabel( Partition* partition, const QString& value ) +{ + partition->setProperty( LABEL_PROPERTY, value ); +} + + +void +reset( Partition* partition ) +{ + // Setting a property to an invalid QVariant is equal to removing it + partition->setProperty( MOUNT_POINT_PROPERTY, QVariant() ); + partition->setProperty( FORMAT_PROPERTY, QVariant() ); + partition->setProperty( FLAGS_PROPERTY, QVariant() ); + partition->setProperty( LABEL_PROPERTY, QVariant() ); +} + +bool +isDirty( Partition* partition ) +{ + if ( LvmDevice::s_DirtyPVs.contains( partition ) ) + { + return true; + } + + return !mountPoint( partition ).isEmpty() || format( partition ) || flags( partition ) != partition->activeFlags(); +} + +} // namespace PartitionInfo diff --git a/calamares/src/modules/partition/core/PartitionInfo.h b/calamares/src/modules/partition/core/PartitionInfo.h new file mode 100644 index 0000000..b4368f0 --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionInfo.h @@ -0,0 +1,62 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef PARTITIONINFO_H +#define PARTITIONINFO_H + +#include +#include + +#include + +class Partition; + +/** + * Functions to store Calamares-specific information in the Qt properties of a + * Partition object. + * + * See README.md for the rationale behind this design. Roughly, these + * functions access **intent** while the existing Partition methods + * access current state. + * + * Properties: + * - mountPoint: which directory will a partition be mounted on the installed + * system. This is different from Partition::mountPoint, which is the + * directory on which a partition is *currently* mounted while the installer + * is running. + * - format: whether this partition should be formatted at install time. + * - label: label to apply to the filesystem in the partition + */ +namespace PartitionInfo +{ + +QString mountPoint( const Partition* partition ); +void setMountPoint( Partition* partition, const QString& value ); + +bool format( const Partition* partition ); +void setFormat( Partition* partition, bool value ); + +PartitionTable::Flags flags( const Partition* partition ); +void setFlags( Partition* partition, PartitionTable::Flags f ); + +QString label( const Partition* partition ); +void setLabel( Partition* partition, const QString& value ); + +void reset( Partition* partition ); + +/** + * Returns true if one of the property has been set. This information is used + * by the UI to decide whether the "Revert" button should be enabled or + * disabled. + */ +bool isDirty( Partition* partition ); + +} // namespace PartitionInfo + +#endif /* PARTITIONINFO_H */ diff --git a/calamares/src/modules/partition/core/PartitionLayout.cpp b/calamares/src/modules/partition/core/PartitionLayout.cpp new file mode 100644 index 0000000..ff04978 --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionLayout.cpp @@ -0,0 +1,378 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2018-2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "utils/Logger.h" + +#include "core/PartitionLayout.h" + +#include "core/KPMHelpers.h" +#include "core/PartUtils.h" +#include "core/PartitionActions.h" +#include "core/PartitionInfo.h" + +#include "utils/Variant.h" + +#include +#include +#include + +PartitionLayout::PartitionLayout() {} + +PartitionLayout::PartitionLayout( const PartitionLayout& layout ) + : m_partLayout( layout.m_partLayout ) +{ +} + +PartitionLayout::~PartitionLayout() {} + +PartitionLayout::PartitionEntry::PartitionEntry() + : partAttributes( 0 ) +{ +} + +PartitionLayout::PartitionEntry::PartitionEntry( FileSystem::Type fs, + const QString& mountPoint, + const QString& size, + const QString& minSize, + const QString& maxSize ) + : partAttributes( 0 ) + , partMountPoint( mountPoint ) + , partFileSystem( fs ) + , partSize( size ) + , partMinSize( minSize ) + , partMaxSize( maxSize ) +{ +} + +PartitionLayout::PartitionEntry::PartitionEntry( const QString& label, + const QString& uuid, + const QString& type, + quint64 attributes, + const QString& mountPoint, + const QString& fs, + const bool& noEncrypt, + const QVariantMap& features, + const QString& size, + const QString& minSize, + const QString& maxSize ) + : partLabel( label ) + , partUUID( uuid ) + , partType( type ) + , partAttributes( attributes ) + , partMountPoint( mountPoint ) + , partNoEncrypt( noEncrypt ) + , partFeatures( features ) + , partSize( size ) + , partMinSize( minSize ) + , partMaxSize( maxSize ) +{ + PartUtils::canonicalFilesystemName( fs, &partFileSystem ); +} + +bool +PartitionLayout::addEntry( const PartitionEntry& entry ) +{ + if ( !entry.isValid() ) + { + return false; + } + + m_partLayout.append( entry ); + + return true; +} + +void +PartitionLayout::init( FileSystem::Type defaultFsType, const QVariantList& config ) +{ + bool ok = true; // bogus argument to getSubMap() + + m_partLayout.clear(); + + for ( const auto& r : config ) + { + QVariantMap pentry = r.toMap(); + + if ( !pentry.contains( "name" ) || !pentry.contains( "size" ) ) + { + cError() << "Partition layout entry #" << config.indexOf( r ) + << "lacks mandatory attributes, switching to default layout."; + m_partLayout.clear(); + break; + } + + if ( !addEntry( { Calamares::getString( pentry, "name" ), + Calamares::getString( pentry, "uuid" ), + Calamares::getString( pentry, "type" ), + Calamares::getUnsignedInteger( pentry, "attributes", 0 ), + Calamares::getString( pentry, "mountPoint" ), + Calamares::getString( pentry, "filesystem", "unformatted" ), + Calamares::getBool( pentry, "noEncrypt", false ), + Calamares::getSubMap( pentry, "features", ok ), + Calamares::getString( pentry, "size", QStringLiteral( "0" ) ), + Calamares::getString( pentry, "minSize", QStringLiteral( "0" ) ), + Calamares::getString( pentry, "maxSize", QStringLiteral( "0" ) ) } ) ) + { + cError() << "Partition layout entry #" << config.indexOf( r ) << "is invalid, switching to default layout."; + m_partLayout.clear(); + break; + } + } + + if ( !m_partLayout.count() ) + { + // Unknown will be translated to defaultFsType at apply-time + addEntry( { FileSystem::Type::Unknown, QString( "/" ), QString( "100%" ) } ); + } + + setDefaultFsType( defaultFsType ); +} + +void +PartitionLayout::setDefaultFsType( FileSystem::Type defaultFsType ) +{ + using FileSystem = FileSystem::Type; + QT_WARNING_PUSH + QT_WARNING_DISABLE_CLANG( "-Wswitch-enum" ) + switch ( defaultFsType ) + { + case FileSystem::Unknown: + case FileSystem::Unformatted: + case FileSystem::Extended: + case FileSystem::LinuxSwap: + case FileSystem::Luks: + case FileSystem::Ocfs2: + case FileSystem::Lvm2_PV: + case FileSystem::Udf: + case FileSystem::Iso9660: + case FileSystem::Luks2: + case FileSystem::LinuxRaidMember: + case FileSystem::BitLocker: + // bad bad + cWarning() << "The selected default FS" << defaultFsType << "is not suitable." + << "Using ext4 instead."; + defaultFsType = FileSystem::Ext4; + break; + case FileSystem::Ext2: + case FileSystem::Ext3: + case FileSystem::Ext4: + case FileSystem::Fat32: + case FileSystem::Ntfs: + case FileSystem::Reiser4: + case FileSystem::ReiserFS: + case FileSystem::Xfs: + case FileSystem::Jfs: + case FileSystem::Btrfs: + case FileSystem::Exfat: + case FileSystem::F2fs: + // ok + break; + case FileSystem::Fat16: + case FileSystem::Hfs: + case FileSystem::HfsPlus: + case FileSystem::Ufs: + case FileSystem::Hpfs: + case FileSystem::Zfs: + case FileSystem::Nilfs2: + case FileSystem::Fat12: + case FileSystem::Apfs: + case FileSystem::Minix: + // weird + cWarning() << "The selected default FS" << defaultFsType << "is unusual, but not wrong."; + break; + default: + cWarning() << "The selected default FS" << defaultFsType << "is not known to Calamares." + << "Using ext4 instead."; + defaultFsType = FileSystem::Ext4; + } + QT_WARNING_POP + + m_defaultFsType = defaultFsType; +} + +QList< Partition* > +PartitionLayout::createPartitions( Device* dev, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + QString luksPassphrase, + PartitionNode* parent, + const PartitionRole& role ) +{ + // Make sure the default FS is sensible; warn and use ext4 if not + setDefaultFsType( m_defaultFsType ); + + QList< Partition* > partList; + // Map each partition entry to its requested size (0 when calculated later) + QMap< const PartitionLayout::PartitionEntry*, qint64 > partSectorsMap; + const qint64 totalSectors = lastSector - firstSector + 1; + qint64 currentSector, availableSectors = totalSectors; + + // Let's check if we have enough space for each partitions, using the size + // propery or the min-size property if unit is in percentage. + for ( const auto& entry : std::as_const( m_partLayout ) ) + { + if ( !entry.partSize.isValid() ) + { + cWarning() << "Partition" << entry.partMountPoint << "size is invalid, skipping..."; + continue; + } + + // Calculate partition size: Rely on "possibly uninitialized use" + // warnings to ensure that all the cases are covered below. + // We need to ignore the percent-defined until later + qint64 sectors = 0; + if ( entry.partSize.unit() != Calamares::Partition::SizeUnit::Percent ) + { + sectors = entry.partSize.toSectors( totalSectors, dev->logicalSize() ); + } + else if ( entry.partMinSize.isValid() ) + { + sectors = entry.partMinSize.toSectors( totalSectors, dev->logicalSize() ); + } + partSectorsMap.insert( &entry, sectors ); + availableSectors -= sectors; + } + + // There is not enough space for all partitions, use the min-size property + // and see if we can do better afterward. + if ( availableSectors < 0 ) + { + availableSectors = totalSectors; + for ( const auto& entry : std::as_const( m_partLayout ) ) + { + qint64 sectors = partSectorsMap.value( &entry ); + if ( entry.partMinSize.isValid() ) + { + sectors = entry.partMinSize.toSectors( totalSectors, dev->logicalSize() ); + partSectorsMap.insert( &entry, sectors ); + } + availableSectors -= sectors; + } + } + + // Assign sectors for percentage-defined partitions. + for ( const auto& entry : std::as_const( m_partLayout ) ) + { + if ( entry.partSize.unit() == Calamares::Partition::SizeUnit::Percent ) + { + qint64 sectors + = entry.partSize.toSectors( availableSectors + partSectorsMap.value( &entry ), dev->logicalSize() ); + if ( entry.partMinSize.isValid() ) + { + sectors = std::max( sectors, entry.partMinSize.toSectors( totalSectors, dev->logicalSize() ) ); + } + if ( entry.partMaxSize.isValid() ) + { + sectors = std::min( sectors, entry.partMaxSize.toSectors( totalSectors, dev->logicalSize() ) ); + } + partSectorsMap.insert( &entry, sectors ); + } + } + + auto correctFS = [ d = m_defaultFsType ]( FileSystem::Type t ) { return t == FileSystem::Type::Unknown ? d : t; }; + + // Create the partitions. + currentSector = firstSector; + availableSectors = totalSectors; + for ( const auto& entry : std::as_const( m_partLayout ) ) + { + // Adjust partition size based on available space. + qint64 sectors = partSectorsMap.value( &entry ); + sectors = std::min( sectors, availableSectors ); + if ( sectors == 0 ) + { + continue; + } + + Partition* part = nullptr; + + // Encryption for zfs is handled in the zfs module, skip encryption on noEncrypt partitions + if ( luksPassphrase.isEmpty() || correctFS( entry.partFileSystem ) == FileSystem::Zfs || entry.partNoEncrypt ) + { + part = KPMHelpers::createNewPartition( parent, + *dev, + role, + correctFS( entry.partFileSystem ), + entry.partLabel, + currentSector, + currentSector + sectors - 1, + KPM_PARTITION_FLAG( None ) ); + } + else + { + part = KPMHelpers::createNewEncryptedPartition( parent, + *dev, + role, + correctFS( entry.partFileSystem ), + entry.partLabel, + currentSector, + currentSector + sectors - 1, + luksFsType, + luksPassphrase, + KPM_PARTITION_FLAG( None ) ); + } + + // For zfs, we need to make the passphrase available to later modules + if ( correctFS( entry.partFileSystem ) == FileSystem::Zfs ) + { + Calamares::GlobalStorage* storage = Calamares::JobQueue::instance()->globalStorage(); + QList< QVariant > zfsInfoList; + QVariantMap zfsInfo; + + // Save the information subsequent modules will need + zfsInfo[ "encrypted" ] = !luksPassphrase.isEmpty() && !entry.partNoEncrypt; + zfsInfo[ "passphrase" ] = luksPassphrase; + zfsInfo[ "mountpoint" ] = entry.partMountPoint; + + // Add it to the list and insert it into global storage + zfsInfoList.append( zfsInfo ); + storage->insert( "zfsInfo", zfsInfoList ); + } + + PartitionInfo::setFormat( part, true ); + PartitionInfo::setMountPoint( part, entry.partMountPoint ); + if ( !entry.partLabel.isEmpty() ) + { + part->setLabel( entry.partLabel ); + part->fileSystem().setLabel( entry.partLabel ); + } + if ( !entry.partUUID.isEmpty() ) + { + part->setUUID( entry.partUUID ); + } + if ( !entry.partType.isEmpty() ) + { + part->setType( entry.partType ); + } + if ( entry.partAttributes ) + { + part->setAttributes( entry.partAttributes ); + } + if ( !entry.partFeatures.isEmpty() ) + { + for ( const auto& k : entry.partFeatures.keys() ) + { + part->fileSystem().addFeature( k, entry.partFeatures.value( k ) ); + } + } + // Some buggy (legacy) BIOSes test if the bootflag of at least one partition is set. + // Otherwise they ignore the device in boot-order, so add it here. + partList.append( part ); + currentSector += sectors; + availableSectors -= sectors; + } + + return partList; +} diff --git a/calamares/src/modules/partition/core/PartitionLayout.h b/calamares/src/modules/partition/core/PartitionLayout.h new file mode 100644 index 0000000..2ff9c7d --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionLayout.h @@ -0,0 +1,131 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018-2019 Collabora Ltd + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONLAYOUT_H +#define PARTITIONLAYOUT_H + +#include "Config.h" +#include "core/PartUtils.h" +#include "partition/PartitionSize.h" + +// KPMcore +#include +#include + +// Qt +#include +#include +#include + +class Partition; + +class PartitionLayout +{ +public: + struct PartitionEntry + { + QString partLabel; + QString partUUID; + QString partType; + quint64 partAttributes = 0; + QString partMountPoint; + FileSystem::Type partFileSystem = FileSystem::Unknown; + bool partNoEncrypt = false; + QVariantMap partFeatures; + Calamares::Partition::PartitionSize partSize; + Calamares::Partition::PartitionSize partMinSize; + Calamares::Partition::PartitionSize partMaxSize; + + /// @brief All-zeroes PartitionEntry + PartitionEntry(); + /** @brief Parse @p mountPoint, @p size, @p minSize and @p maxSize to their respective member variables + * + * Sets a specific FS type (not parsed from string like the other + * constructor). + */ + PartitionEntry( FileSystem::Type fs, + const QString& mountPoint, + const QString& size, + const QString& minSize = QString(), + const QString& maxSize = QString() ); + /// @brief All-field PartitionEntry + PartitionEntry( const QString& label, + const QString& uuid, + const QString& type, + quint64 attributes, + const QString& mountPoint, + const QString& fs, + const bool& noEncrypt, + const QVariantMap& features, + const QString& size, + const QString& minSize = QString(), + const QString& maxSize = QString() ); + /// @brief Copy PartitionEntry + PartitionEntry( const PartitionEntry& e ) = default; + + bool isValid() const + { + if ( !partSize.isValid() + || ( partMinSize.isValid() && partMaxSize.isValid() && partMinSize > partMaxSize ) ) + { + return false; + } + return true; + } + }; + + PartitionLayout(); + PartitionLayout( const PartitionLayout& layout ); + ~PartitionLayout(); + + /** @brief create the configuration from @p config + * + * @p config is a list of partition entries (in QVariant form, + * read from YAML). If no entries are given, then a single + * partition is created with type Unkown. + * + * Any partitions with FS type Unknown will get the default filesystem + * that is set at **apply** time (e.g. when createPartitions() is + * called as well. + * + * @see setDefaultFsType() + */ + void init( FileSystem::Type defaultFsType, const QVariantList& config ); + /** @brief add an entry as if it had been listed in the config + * + * The same comments about filesystem type apply. + */ + bool addEntry( const PartitionEntry& entry ); + + /** @brief set the default filesystem type + * + * Any partitions in the layout with type Unknown will get + * the default type when createPartitions() is called. + */ + void setDefaultFsType( FileSystem::Type defaultFsType ); + + /** + * @brief Apply the current partition layout to the selected drive space. + * @return A list of Partition objects. + */ + QList< Partition* > createPartitions( Device* dev, + qint64 firstSector, + qint64 lastSector, + Config::LuksGeneration luksFsType, + QString luksPassphrase, + PartitionNode* parent, + const PartitionRole& role ); + +private: + QList< PartitionEntry > m_partLayout; + FileSystem::Type m_defaultFsType = FileSystem::Type::Unknown; +}; + +#endif /* PARTITIONLAYOUT_H */ diff --git a/calamares/src/modules/partition/core/PartitionModel.cpp b/calamares/src/modules/partition/core/PartitionModel.cpp new file mode 100644 index 0000000..a9d49dc --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionModel.cpp @@ -0,0 +1,338 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionModel.h" + +#include "core/ColorUtils.h" +#include "core/KPMHelpers.h" +#include "core/PartitionInfo.h" +#include "core/SizeUtils.h" + +#include "partition/FileSystem.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" + +// CalaPM +#include +#include +#include +#include + +// Qt +#include + +using Calamares::Partition::isPartitionFreeSpace; +using Calamares::Partition::isPartitionNew; + +//- ResetHelper -------------------------------------------- +PartitionModel::ResetHelper::ResetHelper( PartitionModel* model ) + : m_model( model ) +{ + m_model->m_lock.lock(); + m_model->beginResetModel(); +} + +PartitionModel::ResetHelper::~ResetHelper() +{ + // We need to unlock the mutex before emitting the reset signal, + // because the reset will cause clients to start looking at the + // (new) data. + m_model->m_lock.unlock(); + m_model->endResetModel(); +} + +//- PartitionModel ----------------------------------------- +PartitionModel::PartitionModel( QObject* parent ) + : QAbstractItemModel( parent ) + , m_device( nullptr ) +{ +} + +void +PartitionModel::init( Device* device, const OsproberEntryList& osproberEntries ) +{ + QMutexLocker lock( &m_lock ); + beginResetModel(); + m_device = device; + m_osproberEntries = osproberEntries; + endResetModel(); +} + +int +PartitionModel::columnCount( const QModelIndex& ) const +{ + return ColumnCount; +} + +int +PartitionModel::rowCount( const QModelIndex& parent ) const +{ + Partition* parentPartition = partitionForIndex( parent ); + if ( parentPartition ) + { + return parentPartition->children().count(); + } + PartitionTable* table = m_device->partitionTable(); + return table ? table->children().count() : 0; +} + +QModelIndex +PartitionModel::index( int row, int column, const QModelIndex& parent ) const +{ + PartitionNode* parentPartition = parent.isValid() ? static_cast< PartitionNode* >( partitionForIndex( parent ) ) + : static_cast< PartitionNode* >( m_device->partitionTable() ); + if ( !parentPartition ) + { + return QModelIndex(); + } + auto lst = parentPartition->children(); + if ( row < 0 || row >= lst.count() ) + { + return QModelIndex(); + } + if ( column < 0 || column >= ColumnCount ) + { + return QModelIndex(); + } + Partition* partition = parentPartition->children().at( row ); + return createIndex( row, column, partition ); +} + +QModelIndex +PartitionModel::parent( const QModelIndex& child ) const +{ + if ( !child.isValid() ) + { + return QModelIndex(); + } + Partition* partition = partitionForIndex( child ); + if ( !partition ) + { + return QModelIndex(); + } + PartitionNode* parentNode = partition->parent(); + if ( parentNode == m_device->partitionTable() ) + { + return QModelIndex(); + } + + int row = 0; + for ( auto p : m_device->partitionTable()->children() ) + { + if ( parentNode == p ) + { + return createIndex( row, 0, parentNode ); + } + ++row; + } + cWarning() << "No parent found!"; + return QModelIndex(); +} + +QVariant +PartitionModel::data( const QModelIndex& index, int role ) const +{ + Partition* partition = partitionForIndex( index ); + if ( !partition ) + { + return QVariant(); + } + + switch ( role ) + { + case Qt::DisplayRole: + { + int col = index.column(); + if ( col == NameColumn ) + { + if ( isPartitionFreeSpace( partition ) ) + { + return tr( "Free Space", "@title" ); + } + else + { + return isPartitionNew( partition ) ? tr( "New Partition", "@title" ) : partition->partitionPath(); + } + } + if ( col == FileSystemColumn ) + { + return Calamares::Partition::prettyNameForFileSystemType( partition->fileSystem().type() ); + } + if ( col == FileSystemLabelColumn ) + { + return partition->fileSystem().label(); + } + if ( col == MountPointColumn ) + { + return PartitionInfo::mountPoint( partition ); + } + if ( col == SizeColumn ) + { + qint64 size = ( partition->lastSector() - partition->firstSector() + 1 ) * m_device->logicalSize(); + return formatByteSize( size ); + } + cDebug() << "Unknown column" << col; + return QVariant(); + } + case Qt::DecorationRole: + if ( index.column() == NameColumn ) + { + return ColorUtils::colorForPartition( partition ); + } + else + { + return QVariant(); + } + case Qt::ToolTipRole: + { + int col = index.column(); + QString name; + if ( col == NameColumn ) + { + if ( isPartitionFreeSpace( partition ) ) + { + name = tr( "Free Space", "@title" ); + } + else + { + name = isPartitionNew( partition ) ? tr( "New Partition", "@title" ) : partition->partitionPath(); + } + } + QString prettyFileSystem = Calamares::Partition::prettyNameForFileSystemType( partition->fileSystem().type() ); + qint64 size = ( partition->lastSector() - partition->firstSector() + 1 ) * m_device->logicalSize(); + QString prettySize = formatByteSize( size ); + return QVariant( name + " " + prettyFileSystem + " " + prettySize ); + } + case SizeRole: + return ( partition->lastSector() - partition->firstSector() + 1 ) * m_device->logicalSize(); + case IsFreeSpaceRole: + return isPartitionFreeSpace( partition ); + + case IsPartitionNewRole: + return isPartitionNew( partition ); + + case FileSystemLabelRole: + if ( partition->fileSystem().supportGetLabel() != FileSystem::cmdSupportNone + && !partition->fileSystem().label().isEmpty() ) + { + return partition->fileSystem().label(); + } + return QVariant(); + + case FileSystemTypeRole: + return partition->fileSystem().type(); + + case PartitionPathRole: + return partition->partitionPath(); + + case PartitionPtrRole: + return QVariant::fromValue( (void*)partition ); + + // Osprober roles: + case OsproberNameRole: + foreach ( const OsproberEntry& osproberEntry, m_osproberEntries ) + { + if ( partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() && osproberEntry.uuid == partition->fileSystem().uuid() ) + { + return osproberEntry.prettyName; + } + } + return QVariant(); + case OsproberPathRole: + foreach ( const OsproberEntry& osproberEntry, m_osproberEntries ) + { + if ( partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() && osproberEntry.uuid == partition->fileSystem().uuid() ) + { + return osproberEntry.path; + } + } + return QVariant(); + case OsproberCanBeResizedRole: + foreach ( const OsproberEntry& osproberEntry, m_osproberEntries ) + { + if ( partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() && osproberEntry.uuid == partition->fileSystem().uuid() ) + { + return osproberEntry.canBeResized; + } + } + return QVariant(); + case OsproberRawLineRole: + foreach ( const OsproberEntry& osproberEntry, m_osproberEntries ) + { + if ( partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() && osproberEntry.uuid == partition->fileSystem().uuid() ) + { + return osproberEntry.line; + } + } + return QVariant(); + case OsproberHomePartitionPathRole: + foreach ( const OsproberEntry& osproberEntry, m_osproberEntries ) + { + if ( partition->fileSystem().supportGetUUID() != FileSystem::cmdSupportNone + && !partition->fileSystem().uuid().isEmpty() && osproberEntry.uuid == partition->fileSystem().uuid() ) + { + return osproberEntry.homePath; + } + } + return QVariant(); + // end Osprober roles. + + default: + return QVariant(); + } +} + +QVariant +PartitionModel::headerData( int section, Qt::Orientation, int role ) const +{ + if ( role != Qt::DisplayRole ) + { + return QVariant(); + } + + switch ( section ) + { + case NameColumn: + return tr( "Name", "@title" ); + case FileSystemColumn: + return tr( "File System", "@title" ); + case FileSystemLabelColumn: + return tr( "File System Label", "@title" ); + case MountPointColumn: + return tr( "Mount Point", "@title" ); + case SizeColumn: + return tr( "Size", "@title" ); + default: + cDebug() << "Unknown column" << section; + return QVariant(); + } +} + +Partition* +PartitionModel::partitionForIndex( const QModelIndex& index ) const +{ + QMutexLocker lock( &m_lock ); + if ( !index.isValid() ) + { + return nullptr; + } + return reinterpret_cast< Partition* >( index.internalPointer() ); +} + + +void +PartitionModel::update() +{ + Q_EMIT dataChanged( index( 0, 0 ), index( rowCount() - 1, columnCount() - 1 ) ); +} diff --git a/calamares/src/modules/partition/core/PartitionModel.h b/calamares/src/modules/partition/core/PartitionModel.h new file mode 100644 index 0000000..ba5e258 --- /dev/null +++ b/calamares/src/modules/partition/core/PartitionModel.h @@ -0,0 +1,116 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef PARTITIONMODEL_H +#define PARTITIONMODEL_H + +#include "OsproberEntry.h" + +// Qt +#include +#include + +class Device; +class Partition; +class PartitionNode; + +/** + * A Qt tree model which exposes the partitions of a device. + * + * Its depth is only more than 1 if the device has extended partitions. + * + * Note on updating: + * + * The Device class does not notify the outside world of changes on the + * Partition objects it owns. Since a Qt model must notify its views *before* + * and *after* making changes, it is important to make use of + * the PartitionModel::ResetHelper class to wrap changes. + * + * This is what PartitionCoreModule does when it create jobs. + */ +class PartitionModel : public QAbstractItemModel +{ + Q_OBJECT +public: + /** + * This helper class must be instantiated on the stack *before* making + * changes to the device represented by this model. It will cause the model + * to Q_EMIT modelAboutToBeReset() when instantiated and modelReset() when + * destructed. + */ + class ResetHelper + { + public: + ResetHelper( PartitionModel* model ); + ~ResetHelper(); + + ResetHelper( const ResetHelper& ) = delete; + ResetHelper& operator=( const ResetHelper& ) = delete; + + private: + PartitionModel* m_model; + }; + + enum + { + // The raw size, as a qlonglong. This is different from the DisplayRole of + // SizeColumn, which is a human-readable string. + SizeRole = Qt::UserRole + 1, + IsFreeSpaceRole, + IsPartitionNewRole, + FileSystemLabelRole, + FileSystemTypeRole, + PartitionPathRole, + PartitionPtrRole, // passed as void*, use sparingly + OsproberNameRole, + OsproberPathRole, + OsproberCanBeResizedRole, + OsproberRawLineRole, + OsproberHomePartitionPathRole + }; + + enum Column + { + NameColumn, + FileSystemColumn, + FileSystemLabelColumn, + MountPointColumn, + SizeColumn, + ColumnCount // Must remain last + }; + + PartitionModel( QObject* parent = nullptr ); + /** + * device must remain alive for the life of PartitionModel + */ + void init( Device* device, const OsproberEntryList& osproberEntries ); + + // QAbstractItemModel API + QModelIndex index( int row, int column, const QModelIndex& parent = QModelIndex() ) const override; + QModelIndex parent( const QModelIndex& child ) const override; + int columnCount( const QModelIndex& parent = QModelIndex() ) const override; + int rowCount( const QModelIndex& parent = QModelIndex() ) const override; + QVariant data( const QModelIndex& index, int role = Qt::DisplayRole ) const override; + QVariant headerData( int section, Qt::Orientation orientation, int role = Qt::DisplayRole ) const override; + + Partition* partitionForIndex( const QModelIndex& index ) const; + + Device* device() const { return m_device; } + + void update(); + +private: + friend class ResetHelper; + + Device* m_device; + OsproberEntryList m_osproberEntries; + mutable QMutex m_lock; +}; + +#endif /* PARTITIONMODEL_H */ diff --git a/calamares/src/modules/partition/core/SizeUtils.h b/calamares/src/modules/partition/core/SizeUtils.h new file mode 100644 index 0000000..155cbd9 --- /dev/null +++ b/calamares/src/modules/partition/core/SizeUtils.h @@ -0,0 +1,28 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITION_CORE_SIZEUTILS_H +#define PARTITION_CORE_SIZEUTILS_H + +#include + +/** @brief Helper function for printing sizes consistently. + * + * Most of Calamares uses a qint64 for partition sizes, so use that + * parameter type. However, the human-visible formatting doesn't need + * to bother with one-byte accuracy (and anyway, a double has at least 50 bits + * at which point we're printing giga (or gibi) bytes). + */ +static inline QString +formatByteSize( qint64 sizeValue ) +{ + return Capacity::formatByteSize( static_cast< double >( sizeValue ) ); +} + +#endif // PARTITION_CORE_SIZEUTILS_H diff --git a/calamares/src/modules/partition/gui/BootInfoWidget.cpp b/calamares/src/modules/partition/gui/BootInfoWidget.cpp new file mode 100644 index 0000000..b4339be --- /dev/null +++ b/calamares/src/modules/partition/gui/BootInfoWidget.cpp @@ -0,0 +1,94 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "BootInfoWidget.h" +#include "core/PartUtils.h" + +#include "utils/Gui.h" +#include "utils/QtCompat.h" +#include "utils/Retranslator.h" + +#include +#include +#include + +BootInfoWidget::BootInfoWidget( QWidget* parent ) + : QWidget( parent ) + , m_bootIcon( new QLabel ) + , m_bootLabel( new QLabel ) +{ + m_bootIcon->setObjectName( "bootInfoIcon" ); + m_bootLabel->setObjectName( "bootInfoLabel" ); + QHBoxLayout* mainLayout = new QHBoxLayout; + setLayout( mainLayout ); + + Calamares::unmarginLayout( mainLayout ); + + mainLayout->addWidget( m_bootIcon ); + mainLayout->addWidget( m_bootLabel ); + + QSize iconSize = Calamares::defaultIconSize(); + + m_bootIcon->setMargin( 0 ); + m_bootIcon->setFixedSize( iconSize ); + m_bootIcon->setPixmap( Calamares::defaultPixmap( Calamares::BootEnvironment, Calamares::Original, iconSize ) ); + + QFontMetrics fm = QFontMetrics( QFont() ); + m_bootLabel->setMinimumWidth( fm.boundingRect( "BIOS" ).width() + Calamares::defaultFontHeight() / 2 ); + m_bootLabel->setAlignment( Qt::AlignCenter ); + + QPalette palette; + palette.setBrush( WindowText, QColor( "#4D4D4D" ) ); //dark grey + + m_bootIcon->setAutoFillBackground( true ); + m_bootLabel->setAutoFillBackground( true ); + m_bootIcon->setPalette( palette ); + m_bootLabel->setPalette( palette ); + + CALAMARES_RETRANSLATE( retranslateUi(); ); +} + +void +BootInfoWidget::retranslateUi() +{ + m_bootIcon->setToolTip( tr( "The boot environment of this system.

    " + "Older x86 systems only support BIOS.
    " + "Modern systems usually use EFI, but " + "may also show up as BIOS if started in compatibility " + "mode." ) ); + + QString bootToolTip; + if ( PartUtils::isEfiSystem() ) + { + m_bootLabel->setText( "EFI " ); + bootToolTip = tr( "This system was started with an EFI " + "boot environment.

    " + "To configure startup from an EFI environment, this installer " + "must deploy a boot loader application, like GRUB" + " or systemd-boot on an " + "EFI System Partition. This is automatic, unless " + "you choose manual partitioning, in which case you must " + "choose it or create it on your own." ); + } + else + { + m_bootLabel->setText( "BIOS" ); + bootToolTip = tr( "This system was started with a BIOS " + "boot environment.

    " + "To configure startup from a BIOS environment, this installer " + "must install a boot loader, like GRUB" + ", either at the beginning of a partition or " + "on the Master Boot Record near the " + "beginning of the partition table (preferred). " + "This is automatic, unless " + "you choose manual partitioning, in which case you must " + "set it up on your own." ); + } + m_bootLabel->setToolTip( bootToolTip ); +} diff --git a/calamares/src/modules/partition/gui/BootInfoWidget.h b/calamares/src/modules/partition/gui/BootInfoWidget.h new file mode 100644 index 0000000..6be3f6e --- /dev/null +++ b/calamares/src/modules/partition/gui/BootInfoWidget.h @@ -0,0 +1,32 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + + +#ifndef BOOTINFOWIDGET_H +#define BOOTINFOWIDGET_H + +#include + +class QLabel; + +class BootInfoWidget : public QWidget +{ + Q_OBJECT +public: + explicit BootInfoWidget( QWidget* parent = nullptr ); + +public slots: + void retranslateUi(); + +private: + QLabel* m_bootIcon; + QLabel* m_bootLabel; +}; + +#endif // BOOTINFOWIDGET_H diff --git a/calamares/src/modules/partition/gui/ChoicePage.cpp b/calamares/src/modules/partition/gui/ChoicePage.cpp new file mode 100644 index 0000000..382c1a7 --- /dev/null +++ b/calamares/src/modules/partition/gui/ChoicePage.cpp @@ -0,0 +1,1821 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2021 Anubhav Choudhary + * SPDX-FileCopyrightText: 2023 Evan James + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ChoicePage.h" + +#include "Config.h" + +#include "core/BootLoaderModel.h" +#include "core/DeviceModel.h" +#include "core/KPMHelpers.h" +#include "core/OsproberEntry.h" +#include "core/PartUtils.h" +#include "core/PartitionActions.h" +#include "core/PartitionCoreModule.h" +#include "core/PartitionInfo.h" +#include "core/PartitionModel.h" +#include "gui/BootInfoWidget.h" +#include "gui/DeviceInfoWidget.h" +#include "gui/PartitionBarsView.h" +#include "gui/PartitionLabelsView.h" +#include "gui/PartitionSplitterWidget.h" +#include "gui/ScanningDialog.h" + +#include "Branding.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/CheckBox.h" +#include "partition/PartitionIterator.h" +#include "partition/PartitionQuery.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/String.h" +#include "utils/Units.h" +#include "widgets/PrettyRadioButton.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using Calamares::Partition::findPartitionByPath; +using Calamares::Partition::isPartitionFreeSpace; +using Calamares::Partition::PartitionIterator; +using Calamares::Widgets::PrettyRadioButton; +using InstallChoice = Config::InstallChoice; +using SwapChoice = Config::SwapChoice; + +/** + * @brief ChoicePage::ChoicePage is the default constructor. Called on startup as part of + * the module loading code path. + * @param parent the QWidget parent. + */ +ChoicePage::ChoicePage( Config* config, QWidget* parent ) + : QWidget( parent ) + , m_config( config ) + , m_nextEnabled( false ) + , m_core( nullptr ) + , m_isEfi( false ) + , m_grp( nullptr ) + , m_alongsideButton( nullptr ) + , m_eraseButton( nullptr ) + , m_replaceButton( nullptr ) + , m_somethingElseButton( nullptr ) + , m_eraseSwapChoiceComboBox( nullptr ) + , m_deviceInfoWidget( nullptr ) + , m_beforePartitionBarsView( nullptr ) + , m_beforePartitionLabelsView( nullptr ) + , m_bootloaderComboBox( nullptr ) + , m_enableEncryptionWidget( true ) +{ + setupUi( this ); + + auto gs = Calamares::JobQueue::instance()->globalStorage(); + + m_enableEncryptionWidget = gs->value( "enableLuksAutomatedPartitioning" ).toBool(); + + // Set up drives combo + m_mainLayout->setDirection( QBoxLayout::TopToBottom ); + m_drivesLayout->setDirection( QBoxLayout::LeftToRight ); + + BootInfoWidget* bootInfoWidget = new BootInfoWidget( this ); + m_drivesLayout->insertWidget( 0, bootInfoWidget ); + m_drivesLayout->insertSpacing( 1, Calamares::defaultFontHeight() / 2 ); + + m_drivesCombo = new QComboBox( this ); + m_mainLayout->setStretchFactor( m_drivesLayout, 0 ); + m_mainLayout->setStretchFactor( m_rightLayout, 1 ); + m_drivesLabel->setBuddy( m_drivesCombo ); + + m_drivesLayout->addWidget( m_drivesCombo ); + + m_deviceInfoWidget = new DeviceInfoWidget; + m_drivesLayout->addWidget( m_deviceInfoWidget ); + m_drivesLayout->addStretch(); + + m_messageLabel->setWordWrap( true ); + m_messageLabel->hide(); + + Calamares::unmarginLayout( m_itemsLayout ); + + // Drive selector + preview + CALAMARES_RETRANSLATE_SLOT( &ChoicePage::retranslate ); + + m_previewBeforeFrame->setSizePolicy( QSizePolicy::Preferred, QSizePolicy::Expanding ); + m_previewAfterFrame->setSizePolicy( QSizePolicy::Preferred, QSizePolicy::Expanding ); + m_previewAfterLabel->hide(); + m_previewAfterFrame->hide(); + m_encryptWidget->hide(); + m_reuseHomeCheckBox->hide(); + gs->insert( "reuseHome", false ); + + updateNextEnabled(); +} + +ChoicePage::~ChoicePage() {} + +void +ChoicePage::retranslate() +{ + retranslateUi( this ); + m_drivesLabel->setText( tr( "Select storage de&vice:", "@label" ) ); + m_previewBeforeLabel->setText( tr( "Current:", "@label" ) ); + m_previewAfterLabel->setText( tr( "After:", "@label" ) ); + + updateSwapChoicesTr(); + updateChoiceButtonsTr(); + updateActionDescriptionsTr(); +} + +/** @brief Sets the @p model for the given @p box and adjusts UI sizes to match. + * + * The model provides data for drawing the items in the model; the + * drawing itself is done by the delegate, which may end up drawing a + * different width in the popup than in the collapsed combo box. + * + * Make the box wide enough to accomodate the whole expanded delegate; + * this avoids cases where the popup would truncate data being drawn + * because the overall box is sized too narrow. + */ +void +setModelToComboBox( QComboBox* box, QAbstractItemModel* model ) +{ + box->setModel( model ); + if ( model->rowCount() > 0 ) + { + QStyleOptionViewItem options; + options.initFrom( box ); + auto delegateSize = box->itemDelegate()->sizeHint( options, model->index( 0, 0 ) ); + box->setMinimumWidth( delegateSize.width() ); + } +} + +void +ChoicePage::init( PartitionCoreModule* core ) +{ + m_core = core; + m_isEfi = PartUtils::isEfiSystem(); + + setupChoices(); + + // We need to do this because a PCM revert invalidates the deviceModel. + connect( core, + &PartitionCoreModule::reverted, + this, + [ = ] + { + setModelToComboBox( m_drivesCombo, core->deviceModel() ); + m_drivesCombo->setCurrentIndex( m_lastSelectedDeviceIndex ); + } ); + setModelToComboBox( m_drivesCombo, core->deviceModel() ); + + connect( m_drivesCombo, qOverload< int >( &QComboBox::currentIndexChanged ), this, &ChoicePage::applyDeviceChoice ); + connect( m_encryptWidget, &EncryptWidget::stateChanged, this, &ChoicePage::onEncryptWidgetStateChanged ); + connect( + m_reuseHomeCheckBox, Calamares::checkBoxStateChangedSignal, this, &ChoicePage::onHomeCheckBoxStateChanged ); + + ChoicePage::applyDeviceChoice(); +} + +/** @brief Creates a combobox with the given choices in it. + * + * Pre-selects the choice given by @p dflt. + * No texts are set -- that happens later by the translator functions. + */ +static inline QComboBox* +createCombo( const QSet< SwapChoice >& s, SwapChoice dflt ) +{ + QComboBox* box = new QComboBox; + for ( SwapChoice c : { SwapChoice::NoSwap, + SwapChoice::SmallSwap, + SwapChoice::FullSwap, + SwapChoice::ReuseSwap, + SwapChoice::SwapFile } ) + { + if ( s.contains( c ) ) + { + box->addItem( QString(), c ); + } + } + + int dfltIndex = box->findData( dflt ); + if ( dfltIndex >= 0 ) + { + box->setCurrentIndex( dfltIndex ); + } + + return box; +} + +/** + * @brief ChoicePage::setupChoices creates PrettyRadioButton objects for the action + * choices. + * @warning This must only run ONCE because it creates signal-slot connections for the + * actions. When an action is triggered, it runs action-specific code that may + * change the internal state of the PCM, and it updates the bottom preview (or + * split) widget. + * Synchronous loading ends here. + */ +void +ChoicePage::setupChoices() +{ + // sample os-prober output: + // /dev/sda2:Windows 7 (loader):Windows:chain + // /dev/sda6::Arch:linux + // + // There are three possibilities we have to consider: + // - There are no operating systems present + // - There is one operating system present + // - There are multiple operating systems present + // + // There are three outcomes we have to provide: + // 1) Wipe+autopartition + // 2) Resize+autopartition + // 3) Manual + // TBD: upgrade option? + + QSize iconSize( Calamares::defaultIconSize().width() * 2, Calamares::defaultIconSize().height() * 2 ); + m_grp = new QButtonGroup( this ); + + m_alongsideButton = new PrettyRadioButton; + m_alongsideButton->setIconSize( iconSize ); + m_alongsideButton->setIcon( + Calamares::defaultPixmap( Calamares::PartitionAlongside, Calamares::Original, iconSize ) ); + m_alongsideButton->addToGroup( m_grp, InstallChoice::Alongside ); + + m_eraseButton = new PrettyRadioButton; + m_eraseButton->setIconSize( iconSize ); + m_eraseButton->setIcon( Calamares::defaultPixmap( Calamares::PartitionEraseAuto, Calamares::Original, iconSize ) ); + m_eraseButton->addToGroup( m_grp, InstallChoice::Erase ); + + m_replaceButton = new PrettyRadioButton; + + m_replaceButton->setIconSize( iconSize ); + m_replaceButton->setIcon( + Calamares::defaultPixmap( Calamares::PartitionReplaceOs, Calamares::Original, iconSize ) ); + m_replaceButton->addToGroup( m_grp, InstallChoice::Replace ); + + // Fill up swap options + if ( m_config->swapChoices().count() > 1 ) + { + m_eraseSwapChoiceComboBox = createCombo( m_config->swapChoices(), m_config->swapChoice() ); + m_eraseButton->addOptionsComboBox( m_eraseSwapChoiceComboBox ); + } + + if ( m_config->eraseFsTypes().count() > 1 ) + { + m_eraseFsTypesChoiceComboBox = new QComboBox; + m_eraseFsTypesChoiceComboBox->addItems( m_config->eraseFsTypes() ); + connect( + m_eraseFsTypesChoiceComboBox, &QComboBox::currentTextChanged, m_config, &Config::setEraseFsTypeChoice ); + connect( m_config, &Config::eraseModeFilesystemChanged, this, &ChoicePage::onActionChanged ); + m_eraseButton->addOptionsComboBox( m_eraseFsTypesChoiceComboBox ); + + // Also offer it for "replace + m_replaceFsTypesChoiceComboBox = new QComboBox; + m_replaceFsTypesChoiceComboBox->addItems( m_config->eraseFsTypes() ); + connect( m_replaceFsTypesChoiceComboBox, + &QComboBox::currentTextChanged, + m_config, + &Config::setReplaceFilesystemChoice ); + connect( m_config, &Config::replaceModeFilesystemChanged, this, &ChoicePage::onActionChanged ); + m_replaceButton->addOptionsComboBox( m_replaceFsTypesChoiceComboBox ); + } + + m_itemsLayout->addWidget( m_alongsideButton ); + m_itemsLayout->addWidget( m_replaceButton ); + m_itemsLayout->addWidget( m_eraseButton ); + + m_somethingElseButton = new PrettyRadioButton; + m_somethingElseButton->setIconSize( iconSize ); + m_somethingElseButton->setIcon( + Calamares::defaultPixmap( Calamares::PartitionManual, Calamares::Original, iconSize ) ); + m_itemsLayout->addWidget( m_somethingElseButton ); + m_somethingElseButton->addToGroup( m_grp, InstallChoice::Manual ); + + m_itemsLayout->addStretch(); + + connect( m_grp, + &QButtonGroup::idToggled, + this, + [ this ]( int id, bool checked ) + { + if ( checked ) // An action was picked. + { + m_config->setInstallChoice( id ); + updateNextEnabled(); + + Q_EMIT actionChosen(); + } + else // An action was unpicked, either on its own or because of another selection. + { + if ( m_grp->checkedButton() == nullptr ) // If no other action is chosen, we must + { + // set m_choice to NoChoice and reset previews. + m_config->setInstallChoice( InstallChoice::NoChoice ); + updateNextEnabled(); + + Q_EMIT actionChosen(); + } + } + } ); + + m_rightLayout->setStretchFactor( m_itemsLayout, 1 ); + m_rightLayout->setStretchFactor( m_previewBeforeFrame, 0 ); + m_rightLayout->setStretchFactor( m_previewAfterFrame, 0 ); + + connect( this, &ChoicePage::actionChosen, this, &ChoicePage::onActionChanged ); + if ( m_eraseSwapChoiceComboBox ) + { + connect( m_eraseSwapChoiceComboBox, + QOverload< int >::of( &QComboBox::currentIndexChanged ), + this, + &ChoicePage::onEraseSwapChoiceChanged ); + } + + updateSwapChoicesTr(); + updateChoiceButtonsTr(); +} + +/** + * @brief ChoicePage::selectedDevice queries the device picker (which may be a combo or + * a list view) to get a pointer to the currently selected Device. + * @return a Device pointer, valid in the current state of the PCM, or nullptr if + * something goes wrong. + */ +Device* +ChoicePage::selectedDevice() +{ + Device* const currentDevice + = m_core->deviceModel()->deviceForIndex( m_core->deviceModel()->index( m_drivesCombo->currentIndex() ) ); + return currentDevice; +} + +void +ChoicePage::hideButtons() +{ + m_eraseButton->hide(); + m_replaceButton->hide(); + m_alongsideButton->hide(); + m_somethingElseButton->hide(); +} + +void +ChoicePage::checkInstallChoiceRadioButton( InstallChoice c ) +{ + QSignalBlocker b( m_grp ); + m_grp->setExclusive( false ); + // If c == InstallChoice::NoChoice none will match and all are deselected + m_eraseButton->setChecked( InstallChoice::Erase == c ); + m_replaceButton->setChecked( InstallChoice::Replace == c ); + m_alongsideButton->setChecked( InstallChoice::Alongside == c ); + m_somethingElseButton->setChecked( InstallChoice::Manual == c ); + m_grp->setExclusive( true ); +} + +/** + * @brief ChoicePage::applyDeviceChoice handler for the selected event of the device + * picker. Calls ChoicePage::selectedDevice() to get the current Device*, then + * updates the preview widget for the on-disk state (calls ChoicePage:: + * updateDeviceStatePreview()) and finally sets up the available actions and their + * text by calling ChoicePage::setupActions(). + */ +void +ChoicePage::applyDeviceChoice() +{ + if ( !selectedDevice() ) + { + hideButtons(); + return; + } + + if ( m_core->isDirty() ) + { + ScanningDialog::run( + QtConcurrent::run( + [ = ] + { + QMutexLocker locker( &m_coreMutex ); + m_core->revertAllDevices(); + } ), + [ this ] { continueApplyDeviceChoice(); }, + this ); + } + else + { + continueApplyDeviceChoice(); + } +} + +void +ChoicePage::continueApplyDeviceChoice() +{ + Device* currd = selectedDevice(); + + // The device should only be nullptr immediately after a PCM reset. + // applyDeviceChoice() will be called again momentarily as soon as we handle the + // PartitionCoreModule::reverted signal. + if ( !currd ) + { + hideButtons(); + return; + } + + updateDeviceStatePreview(); + + // Preview setup done. Now we show/hide choices as needed. + setupActions(); + + cDebug() << "Previous device" << m_lastSelectedDeviceIndex << "new device" << m_drivesCombo->currentIndex(); + if ( m_lastSelectedDeviceIndex != m_drivesCombo->currentIndex() ) + { + m_lastSelectedDeviceIndex = m_drivesCombo->currentIndex(); + m_config->setInstallChoice( m_config->initialInstallChoice() ); + checkInstallChoiceRadioButton( m_config->installChoice() ); + } + + Q_EMIT actionChosen(); + Q_EMIT deviceChosen(); +} + +void +ChoicePage::onActionChanged() +{ + if ( m_enableEncryptionWidget ) + { + if ( m_config->installChoice() == InstallChoice::Erase && m_eraseFsTypesChoiceComboBox ) + { + m_encryptWidget->setFilesystem( FileSystem::typeForName( m_eraseFsTypesChoiceComboBox->currentText() ) ); + } + else if ( m_config->installChoice() == InstallChoice::Replace && m_replaceFsTypesChoiceComboBox ) + { + m_encryptWidget->setFilesystem( FileSystem::typeForName( m_replaceFsTypesChoiceComboBox->currentText() ) ); + } + } + + Device* currd = selectedDevice(); + if ( currd ) + { + applyActionChoice( m_config->installChoice() ); + } + + updateNextEnabled(); +} + +void +ChoicePage::onEraseSwapChoiceChanged() +{ + if ( m_eraseSwapChoiceComboBox ) + { + m_config->setSwapChoice( m_eraseSwapChoiceComboBox->currentData().toInt() ); + onActionChanged(); + } +} + +void +ChoicePage::applyActionChoice( InstallChoice choice ) +{ + cDebug() << "InstallChoice" << choice << Config::installChoiceNames().find( choice ); + m_beforePartitionBarsView->selectionModel()->disconnect( SIGNAL( currentRowChanged( QModelIndex, QModelIndex ) ) ); + auto priorSelection = m_beforePartitionBarsView->selectionModel()->currentIndex(); + m_beforePartitionBarsView->selectionModel()->clearSelection(); + m_beforePartitionBarsView->selectionModel()->clearCurrentIndex(); + + switch ( choice ) + { + case InstallChoice::Erase: + { + auto gs = Calamares::JobQueue::instance()->globalStorage(); + PartitionActions::Choices::AutoPartitionOptions options { gs->value( "defaultPartitionTableType" ).toString(), + m_config->eraseFsType(), + m_config->luksFileSystemType(), + m_encryptWidget->passphrase(), + gs->value( "efiSystemPartition" ).toString(), + Calamares::GiBtoBytes( + gs->value( "requiredStorageGiB" ).toDouble() ), + m_config->swapChoice() }; + + if ( m_core->isDirty() ) + { + ScanningDialog::run( + QtConcurrent::run( + [ = ] + { + QMutexLocker locker( &m_coreMutex ); + m_core->revertDevice( selectedDevice() ); + } ), + [ = ] + { + PartitionActions::doAutopartition( m_core, selectedDevice(), options ); + Q_EMIT deviceChosen(); + }, + this ); + } + else + { + PartitionActions::doAutopartition( m_core, selectedDevice(), options ); + Q_EMIT deviceChosen(); + } + } + break; + case InstallChoice::Replace: + if ( m_core->isDirty() ) + { + ScanningDialog::run( + QtConcurrent::run( + [ = ] + { + QMutexLocker locker( &m_coreMutex ); + m_core->revertDevice( selectedDevice() ); + } ), + [] {}, + this ); + } + connect( m_beforePartitionBarsView->selectionModel(), + &QItemSelectionModel::currentRowChanged, + this, + &ChoicePage::onPartitionToReplaceSelected, + Qt::UniqueConnection ); + + // Maintain the selection for replace + if ( priorSelection.isValid() ) + { + m_beforePartitionBarsView->selectionModel()->setCurrentIndex( priorSelection, QItemSelectionModel::Select ); + } + break; + + case InstallChoice::Alongside: + if ( m_core->isDirty() ) + { + ScanningDialog::run( + QtConcurrent::run( + [ = ] + { + QMutexLocker locker( &m_coreMutex ); + m_core->revertDevice( selectedDevice() ); + } ), + [ this ] + { + // We need to reupdate after reverting because the splitter widget is + // not a true view. + updateActionChoicePreview( m_config->installChoice() ); + updateNextEnabled(); + }, + this ); + } + + connect( m_beforePartitionBarsView->selectionModel(), + &QItemSelectionModel::currentRowChanged, + this, + &ChoicePage::doAlongsideSetupSplitter, + Qt::UniqueConnection ); + break; + case InstallChoice::Manual: + if ( m_core->isDirty() ) + { + ScanningDialog::run( + QtConcurrent::run( + [ = ] + { + QMutexLocker locker( &m_coreMutex ); + m_core->revertDevice( selectedDevice() ); + } ), + [] {}, + this ); + } + break; + case InstallChoice::NoChoice: + break; + } + updateNextEnabled(); + updateActionChoicePreview( choice ); +} + +void +ChoicePage::doAlongsideSetupSplitter( const QModelIndex& current, const QModelIndex& previous ) +{ + Q_UNUSED( previous ) + if ( !current.isValid() ) + { + return; + } + + if ( !m_afterPartitionSplitterWidget ) + { + return; + } + + const PartitionModel* modl = qobject_cast< const PartitionModel* >( current.model() ); + if ( !modl ) + { + return; + } + + Partition* part = modl->partitionForIndex( current ); + if ( !part ) + { + cDebug() << "Partition not found for index" << current; + return; + } + + double requiredStorageGB + = Calamares::JobQueue::instance()->globalStorage()->value( "requiredStorageGiB" ).toDouble(); + + qint64 requiredStorageB = Calamares::GiBtoBytes( requiredStorageGB + 0.1 + 2.0 ); + + m_afterPartitionSplitterWidget->setSplitPartition( part->partitionPath(), + qRound64( part->used() * 1.1 ), + part->capacity() - requiredStorageB, + part->capacity() / 2 ); + + if ( m_isEfi ) + { + setupEfiSystemPartitionSelector(); + } + + cDebug() << "Partition selected for Alongside."; + + updateNextEnabled(); +} + +void +ChoicePage::onEncryptWidgetStateChanged() +{ + EncryptWidget::Encryption state = m_encryptWidget->state(); + if ( m_config->installChoice() == InstallChoice::Erase ) + { + if ( state == EncryptWidget::Encryption::Confirmed || state == EncryptWidget::Encryption::Disabled ) + { + applyActionChoice( m_config->installChoice() ); + } + } + else if ( m_config->installChoice() == InstallChoice::Replace ) + { + if ( m_beforePartitionBarsView && m_beforePartitionBarsView->selectionModel()->currentIndex().isValid() + && ( state == EncryptWidget::Encryption::Confirmed || state == EncryptWidget::Encryption::Disabled ) ) + { + doReplaceSelectedPartition( m_beforePartitionBarsView->selectionModel()->currentIndex() ); + } + } + updateNextEnabled(); +} + +void +ChoicePage::onHomeCheckBoxStateChanged() +{ + if ( m_config->installChoice() == InstallChoice::Replace + && m_beforePartitionBarsView->selectionModel()->currentIndex().isValid() ) + { + doReplaceSelectedPartition( m_beforePartitionBarsView->selectionModel()->currentIndex() ); + } +} + +void +ChoicePage::onLeave() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + const bool useLuksPassphrase = ( m_encryptWidget->state() == EncryptWidget::Encryption::Confirmed ); + const QString storedLuksPassphrase + = useLuksPassphrase ? Calamares::String::obscure( m_encryptWidget->passphrase() ) : QString(); + gs->insert( "luksPassphrase", storedLuksPassphrase ); + + if ( m_config->installChoice() == InstallChoice::Alongside ) + { + if ( m_afterPartitionSplitterWidget->splitPartitionSize() >= 0 + && m_afterPartitionSplitterWidget->newPartitionSize() >= 0 ) + { + doAlongsideApply(); + } + } + + if ( m_isEfi + && ( m_config->installChoice() == InstallChoice::Alongside + || m_config->installChoice() == InstallChoice::Replace ) ) + { + QList< Partition* > efiSystemPartitions = m_core->efiSystemPartitions(); + if ( efiSystemPartitions.count() == 1 ) + { + PartitionInfo::setMountPoint( + efiSystemPartitions.first(), + Calamares::JobQueue::instance()->globalStorage()->value( "efiSystemPartition" ).toString() ); + } + else if ( efiSystemPartitions.count() > 1 && m_efiComboBox ) + { + PartitionInfo::setMountPoint( + efiSystemPartitions.at( m_efiComboBox->currentIndex() ), + Calamares::JobQueue::instance()->globalStorage()->value( "efiSystemPartition" ).toString() ); + } + else + { + cError() << "cannot set up EFI system partition.\nESP count:" << efiSystemPartitions.count() + << "\nm_efiComboBox:" << m_efiComboBox; + } + } + else // installPath is then passed to the bootloader module for MBR setup + { + if ( !m_isEfi ) + { + if ( m_bootloaderComboBox.isNull() ) + { + auto d_p = selectedDevice(); + if ( d_p ) + { + m_core->setBootLoaderInstallPath( d_p->deviceNode() ); + } + else + { + cWarning() << "No device selected for bootloader."; + } + } + else + { + QVariant var = m_bootloaderComboBox->currentData( BootLoaderModel::BootLoaderPathRole ); + if ( !var.isValid() ) + { + return; + } + m_core->setBootLoaderInstallPath( var.toString() ); + } + } + } +} + +void +ChoicePage::doAlongsideApply() +{ + Q_ASSERT( m_afterPartitionSplitterWidget->splitPartitionSize() >= 0 ); + Q_ASSERT( m_afterPartitionSplitterWidget->newPartitionSize() >= 0 ); + + QMutexLocker locker( &m_coreMutex ); + + QString path = m_beforePartitionBarsView->selectionModel() + ->currentIndex() + .data( PartitionModel::PartitionPathRole ) + .toString(); + + DeviceModel* dm = m_core->deviceModel(); + for ( int i = 0; i < dm->rowCount(); ++i ) + { + Device* dev = dm->deviceForIndex( dm->index( i ) ); + Partition* candidate = findPartitionByPath( { dev }, path ); + if ( candidate ) + { + qint64 firstSector = candidate->firstSector(); + qint64 oldLastSector = candidate->lastSector(); + qint64 newLastSector + = firstSector + m_afterPartitionSplitterWidget->splitPartitionSize() / dev->logicalSize(); + + m_core->resizePartition( dev, candidate, firstSector, newLastSector ); + m_core->layoutApply( dev, + newLastSector + 2, + oldLastSector, + m_config->luksFileSystemType(), + m_encryptWidget->passphrase(), + candidate->parent(), + candidate->roles() ); + m_core->dumpQueue(); + + break; + } + } +} + +void +ChoicePage::onPartitionToReplaceSelected( const QModelIndex& current, const QModelIndex& previous ) +{ + Q_UNUSED( previous ) + if ( !current.isValid() ) + { + return; + } + + // Reset state on selection regardless of whether this will be used. + m_reuseHomeCheckBox->setChecked( false ); + + doReplaceSelectedPartition( current ); +} + +void +ChoicePage::doReplaceSelectedPartition( const QModelIndex& current ) +{ + if ( !current.isValid() ) + { + return; + } + + // This will be deleted by the second lambda, below. + QString* homePartitionPath = new QString(); + + ScanningDialog::run( + QtConcurrent::run( + [ this, current, homePartitionPath ]( bool doReuseHomePartition ) + { + QMutexLocker locker( &m_coreMutex ); + + if ( m_core->isDirty() ) + { + m_core->revertDevice( selectedDevice() ); + } + + // if the partition is unallocated(free space), we don't replace it but create new one + // with the same first and last sector + Partition* selectedPartition + = static_cast< Partition* >( current.data( PartitionModel::PartitionPtrRole ).value< void* >() ); + if ( isPartitionFreeSpace( selectedPartition ) ) + { + //NOTE: if the selected partition is free space, we don't deal with + // a separate /home partition at all because there's no existing + // rootfs to read it from. + PartitionRole newRoles = PartitionRole( PartitionRole::Primary ); + PartitionNode* newParent = selectedDevice()->partitionTable(); + + if ( selectedPartition->parent() ) + { + Partition* parent = dynamic_cast< Partition* >( selectedPartition->parent() ); + if ( parent && parent->roles().has( PartitionRole::Extended ) ) + { + newRoles = PartitionRole( PartitionRole::Logical ); + newParent = findPartitionByPath( { selectedDevice() }, parent->partitionPath() ); + } + } + + m_core->layoutApply( selectedDevice(), + selectedPartition->firstSector(), + selectedPartition->lastSector(), + m_config->luksFileSystemType(), + m_encryptWidget->passphrase(), + newParent, + newRoles ); + } + else + { + // We can't use the PartitionPtrRole because we need to make changes to the + // main DeviceModel, not the immutable copy. + QString partPath = current.data( PartitionModel::PartitionPathRole ).toString(); + selectedPartition = findPartitionByPath( { selectedDevice() }, partPath ); + if ( selectedPartition ) + { + // Find out is the selected partition has a rootfs. If yes, then make the + // m_reuseHomeCheckBox visible and set its text to something meaningful. + homePartitionPath->clear(); + for ( const OsproberEntry& osproberEntry : m_core->osproberEntries() ) + { + if ( osproberEntry.path == partPath ) + { + *homePartitionPath = osproberEntry.homePath; + } + } + if ( homePartitionPath->isEmpty() ) + { + doReuseHomePartition = false; + } + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + PartitionActions::doReplacePartition( m_core, + selectedDevice(), + selectedPartition, + { gs->value( "defaultPartitionType" ).toString(), + m_config->replaceModeFilesystem(), + m_config->luksFileSystemType(), + m_encryptWidget->passphrase() } ); + Partition* homePartition = findPartitionByPath( { selectedDevice() }, *homePartitionPath ); + + if ( homePartition && doReuseHomePartition ) + { + PartitionInfo::setMountPoint( homePartition, "/home" ); + gs->insert( "reuseHome", true ); + } + else + { + gs->insert( "reuseHome", false ); + } + } + } + }, + m_reuseHomeCheckBox->isChecked() ), + [ this, homePartitionPath ] + { + m_reuseHomeCheckBox->setVisible( !homePartitionPath->isEmpty() ); + if ( !homePartitionPath->isEmpty() ) + { + m_reuseHomeCheckBox->setText( tr( "Reuse %1 as home partition for %2", "@label" ) + .arg( *homePartitionPath ) + .arg( Calamares::Branding::instance()->shortProductName() ) ); + } + delete homePartitionPath; + + if ( m_isEfi ) + { + setupEfiSystemPartitionSelector(); + } + + updateNextEnabled(); + if ( !m_bootloaderComboBox.isNull() && m_bootloaderComboBox->currentIndex() < 0 ) + { + m_bootloaderComboBox->setCurrentIndex( m_lastSelectedDeviceIndex ); + } + }, + this ); +} + +/** + * @brief clear and then rebuild the contents of the preview widget + * + * The preview widget for the current disk is completely re-constructed + * based on the on-disk state. This also triggers a rescan in the + * PCM to get a Device* copy that's unaffected by subsequent PCM changes. + */ +void +ChoicePage::updateDeviceStatePreview() +{ + //FIXME: this needs to be made async because the rescan can block the UI thread for + // a while. --Teo 10/2015 + Device* currentDevice = selectedDevice(); + Q_ASSERT( currentDevice ); + QMutexLocker locker( &m_previewsMutex ); + + cDebug() << "Updating partitioning state widgets."; + qDeleteAll( m_previewBeforeFrame->children() ); + + auto layout = m_previewBeforeFrame->layout(); + if ( layout ) + { + layout->deleteLater(); // Doesn't like nullptr + } + + layout = new QVBoxLayout; + m_previewBeforeFrame->setLayout( layout ); + Calamares::unmarginLayout( layout ); + layout->setSpacing( 6 ); + + PartitionBarsView::NestedPartitionsMode mode + = Calamares::JobQueue::instance()->globalStorage()->value( "drawNestedPartitions" ).toBool() + ? PartitionBarsView::DrawNestedPartitions + : PartitionBarsView::NoNestedPartitions; + m_beforePartitionBarsView = new PartitionBarsView( m_previewBeforeFrame ); + m_beforePartitionBarsView->setNestedPartitionsMode( mode ); + m_beforePartitionLabelsView = new PartitionLabelsView( m_previewBeforeFrame ); + m_beforePartitionLabelsView->setExtendedPartitionHidden( mode == PartitionBarsView::NoNestedPartitions ); + + Device* deviceBefore = m_core->immutableDeviceCopy( currentDevice ); + + PartitionModel* model = new PartitionModel( m_beforePartitionBarsView ); + model->init( deviceBefore, m_core->osproberEntries() ); + + m_beforePartitionBarsView->setModel( model ); + m_beforePartitionLabelsView->setModel( model ); + + // Make the bars and labels view use the same selectionModel. + auto sm = m_beforePartitionLabelsView->selectionModel(); + m_beforePartitionLabelsView->setSelectionModel( m_beforePartitionBarsView->selectionModel() ); + if ( sm ) + { + sm->deleteLater(); + } + + switch ( m_config->installChoice() ) + { + case InstallChoice::Replace: + case InstallChoice::Alongside: + m_beforePartitionBarsView->setSelectionMode( QAbstractItemView::SingleSelection ); + m_beforePartitionLabelsView->setSelectionMode( QAbstractItemView::SingleSelection ); + break; + case InstallChoice::NoChoice: + case InstallChoice::Erase: + case InstallChoice::Manual: + m_beforePartitionBarsView->setSelectionMode( QAbstractItemView::NoSelection ); + m_beforePartitionLabelsView->setSelectionMode( QAbstractItemView::NoSelection ); + } + + layout->addWidget( m_beforePartitionBarsView ); + layout->addWidget( m_beforePartitionLabelsView ); +} + +/** + * @brief rebuild the contents of the preview for the PCM-proposed state. + * + * No rescans here, this should be immediate. + * + * @param choice the chosen partitioning action. + */ +void +ChoicePage::updateActionChoicePreview( InstallChoice choice ) +{ + Device* currentDevice = selectedDevice(); + Q_ASSERT( currentDevice ); + + QMutexLocker locker( &m_previewsMutex ); + + cDebug() << "Updating partitioning preview widgets."; + qDeleteAll( m_previewAfterFrame->children() ); + + auto oldlayout = m_previewAfterFrame->layout(); + if ( oldlayout ) + { + oldlayout->deleteLater(); + } + + QVBoxLayout* layout = new QVBoxLayout; + m_previewAfterFrame->setLayout( layout ); + Calamares::unmarginLayout( layout ); + layout->setSpacing( 6 ); + + PartitionBarsView::NestedPartitionsMode mode + = Calamares::JobQueue::instance()->globalStorage()->value( "drawNestedPartitions" ).toBool() + ? PartitionBarsView::DrawNestedPartitions + : PartitionBarsView::NoNestedPartitions; + + m_reuseHomeCheckBox->hide(); + Calamares::JobQueue::instance()->globalStorage()->insert( "reuseHome", false ); + + switch ( choice ) + { + case InstallChoice::Alongside: + { + if ( m_enableEncryptionWidget ) + { + m_encryptWidget->show(); + if ( m_config->preCheckEncryption() && !m_preCheckActivated ) + { + m_encryptWidget->setEncryptionCheckbox( true ); + m_preCheckActivated = true; + } + } + m_previewBeforeLabel->setText( tr( "Current:", "@label" ) ); + m_selectLabel->setText( tr( "Select a partition to shrink, " + "then drag the bottom bar to resize" ) ); + m_selectLabel->show(); + + m_afterPartitionSplitterWidget = new PartitionSplitterWidget( m_previewAfterFrame ); + m_afterPartitionSplitterWidget->init( selectedDevice(), mode == PartitionBarsView::DrawNestedPartitions ); + layout->addWidget( m_afterPartitionSplitterWidget ); + + QLabel* sizeLabel = new QLabel( m_previewAfterFrame ); + layout->addWidget( sizeLabel ); + sizeLabel->setWordWrap( true ); + + if ( !m_isEfi ) + { + layout->addWidget( createBootloaderPanel() ); + } + + connect( m_afterPartitionSplitterWidget, + &PartitionSplitterWidget::partitionResized, + this, + [ this, sizeLabel ]( const QString& path, qint64 size, qint64 sizeNext ) + { + Q_UNUSED( path ) + sizeLabel->setText( + tr( "%1 will be shrunk to %2MiB and a new " + "%3MiB partition will be created for %4.", + "@info, %1 is partition name, %4 is product name" ) + .arg( m_beforePartitionBarsView->selectionModel()->currentIndex().data().toString() ) + .arg( Calamares::BytesToMiB( size ) ) + .arg( Calamares::BytesToMiB( sizeNext ) ) + .arg( Calamares::Branding::instance()->shortProductName() ) ); + } ); + + m_previewAfterFrame->show(); + m_previewAfterLabel->show(); + + SelectionFilter filter = []( const QModelIndex& index ) + { + return PartUtils::canBeResized( + static_cast< Partition* >( index.data( PartitionModel::PartitionPtrRole ).value< void* >() ), + Logger::Once() ); + }; + m_beforePartitionBarsView->setSelectionFilter( filter ); + m_beforePartitionLabelsView->setSelectionFilter( filter ); + + break; + } + case InstallChoice::Erase: + case InstallChoice::Replace: + { + if ( shouldShowEncryptWidget( choice ) ) + { + m_encryptWidget->show(); + if ( m_config->preCheckEncryption() && !m_preCheckActivated ) + { + m_encryptWidget->setEncryptionCheckbox( true ); + m_preCheckActivated = true; + } + } + m_previewBeforeLabel->setText( tr( "Current:", "@label" ) ); + m_afterPartitionBarsView = new PartitionBarsView( m_previewAfterFrame ); + m_afterPartitionBarsView->setNestedPartitionsMode( mode ); + m_afterPartitionLabelsView = new PartitionLabelsView( m_previewAfterFrame ); + m_afterPartitionLabelsView->setExtendedPartitionHidden( mode == PartitionBarsView::NoNestedPartitions ); + m_afterPartitionLabelsView->setCustomNewRootLabel( + Calamares::Branding::instance()->string( Calamares::Branding::BootloaderEntryName ) ); + + PartitionModel* model = m_core->partitionModelForDevice( selectedDevice() ); + + // The QObject parents tree is meaningful for memory management here, + // see qDeleteAll above. + m_afterPartitionBarsView->setModel( model ); + m_afterPartitionLabelsView->setModel( model ); + m_afterPartitionBarsView->setSelectionMode( QAbstractItemView::NoSelection ); + m_afterPartitionLabelsView->setSelectionMode( QAbstractItemView::NoSelection ); + + layout->addWidget( m_afterPartitionBarsView ); + layout->addWidget( m_afterPartitionLabelsView ); + + if ( !m_isEfi ) + { + layout->addWidget( createBootloaderPanel() ); + } + + m_previewAfterFrame->show(); + m_previewAfterLabel->show(); + + if ( m_config->installChoice() == InstallChoice::Erase ) + { + m_selectLabel->hide(); + } + else + { + SelectionFilter filter = []( const QModelIndex& index ) + { + return PartUtils::canBeReplaced( + static_cast< Partition* >( index.data( PartitionModel::PartitionPtrRole ).value< void* >() ), + Logger::Once() ); + }; + m_beforePartitionBarsView->setSelectionFilter( filter ); + m_beforePartitionLabelsView->setSelectionFilter( filter ); + + m_selectLabel->show(); + m_selectLabel->setText( tr( "Select a partition to install on", "@label" ) ); + } + + break; + } + case InstallChoice::NoChoice: + case InstallChoice::Manual: + m_selectLabel->hide(); + m_previewAfterFrame->hide(); + m_previewBeforeLabel->setText( tr( "Current:", "@label" ) ); + m_previewAfterLabel->hide(); + m_encryptWidget->hide(); + break; + } + + if ( m_isEfi + && ( m_config->installChoice() == InstallChoice::Alongside + || m_config->installChoice() == InstallChoice::Replace ) ) + { + QHBoxLayout* efiLayout = new QHBoxLayout; + layout->addLayout( efiLayout ); + m_efiLabel = new QLabel( m_previewAfterFrame ); + efiLayout->addWidget( m_efiLabel ); + m_efiComboBox = new QComboBox( m_previewAfterFrame ); + efiLayout->addWidget( m_efiComboBox ); + m_efiLabel->setBuddy( m_efiComboBox ); + m_efiComboBox->hide(); + efiLayout->addStretch(); + } + + // Also handle selection behavior on beforeFrame. + QAbstractItemView::SelectionMode previewSelectionMode = QAbstractItemView::NoSelection; + switch ( m_config->installChoice() ) + { + case InstallChoice::Replace: + case InstallChoice::Alongside: + previewSelectionMode = QAbstractItemView::SingleSelection; + break; + case InstallChoice::NoChoice: + case InstallChoice::Erase: + case InstallChoice::Manual: + previewSelectionMode = QAbstractItemView::NoSelection; + } + + m_beforePartitionBarsView->setSelectionMode( previewSelectionMode ); + m_beforePartitionLabelsView->setSelectionMode( previewSelectionMode ); + + updateNextEnabled(); +} + +void +ChoicePage::setupEfiSystemPartitionSelector() +{ + Q_ASSERT( m_isEfi ); + + // Only the already existing ones: + QList< Partition* > efiSystemPartitions = m_core->efiSystemPartitions(); + + if ( efiSystemPartitions.count() == 0 ) //should never happen + { + m_efiLabel->setText( tr( "An EFI system partition cannot be found anywhere " + "on this system. Please go back and use manual " + "partitioning to set up %1.", + "@info, %1 is product name" ) + .arg( Calamares::Branding::instance()->shortProductName() ) ); + updateNextEnabled(); + } + else if ( efiSystemPartitions.count() == 1 ) //probably most usual situation + { + m_efiLabel->setText( tr( "The EFI system partition at %1 will be used for " + "starting %2.", + "@info, %1 is partition path, %2 is product name" ) + .arg( efiSystemPartitions.first()->partitionPath() ) + .arg( Calamares::Branding::instance()->shortProductName() ) ); + } + else + { + m_efiComboBox->show(); + m_efiLabel->setText( tr( "EFI system partition:", "@label" ) ); + for ( int i = 0; i < efiSystemPartitions.count(); ++i ) + { + Partition* efiPartition = efiSystemPartitions.at( i ); + m_efiComboBox->addItem( efiPartition->partitionPath(), i ); + + // We pick an ESP on the currently selected device, if possible + if ( efiPartition->devicePath() == selectedDevice()->deviceNode() && efiPartition->number() == 1 ) + { + m_efiComboBox->setCurrentIndex( i ); + } + } + } +} + +static inline void +force_uncheck( QButtonGroup* grp, PrettyRadioButton* button ) +{ + button->hide(); + grp->setExclusive( false ); + button->setChecked( false ); + grp->setExclusive( true ); +} + +static inline QDebug& +operator<<( QDebug& s, PartitionIterator& it ) +{ + s << ( ( *it ) ? ( *it )->deviceNode() : QString( "" ) ); + return s; +} + +QString +describePartitionTypes( const QStringList& types ) +{ + if ( types.empty() ) + { + return QCoreApplication::translate( + ChoicePage::staticMetaObject.className(), "any", "any partition-table type" ); + } + if ( types.size() == 1 ) + { + return types.first(); + } + if ( types.size() == 2 ) + { + return QCoreApplication::translate( + ChoicePage::staticMetaObject.className(), "%1 or %2", "partition-table types" ) + .arg( types.at( 0 ), types.at( 1 ) ); + } + // More than two, rather unlikely + return types.join( ", " ); +} + +/** + * @brief ChoicePage::setupActions happens every time a new Device* is selected in the + * device picker. Sets up the text and visibility of the partitioning actions based + * on the currently selected Device*, bootloader and os-prober output. + */ +void +ChoicePage::setupActions() +{ + Logger::Once o; + + Device* currentDevice = selectedDevice(); + OsproberEntryList osproberEntriesForCurrentDevice = getOsproberEntriesForDevice( currentDevice ); + + cDebug() << o << "Setting up actions for" << currentDevice->deviceNode() << "with" + << osproberEntriesForCurrentDevice.count() << "entries."; + + if ( currentDevice->partitionTable() ) + { + m_deviceInfoWidget->setPartitionTableType( currentDevice->partitionTable()->type() ); + } + else + { + m_deviceInfoWidget->setPartitionTableType( PartitionTable::unknownTableType ); + } + + if ( m_config->allowManualPartitioning() ) + { + m_somethingElseButton->show(); + } + else + { + force_uncheck( m_grp, m_somethingElseButton ); + } + + bool atLeastOneCanBeResized = false; + bool atLeastOneCanBeReplaced = false; + bool atLeastOneIsMounted = false; // Suppress 'erase' if so + bool isInactiveRAID = false; + bool matchTableType = false; + + if ( currentDevice->type() == Device::Type::SoftwareRAID_Device + && static_cast< SoftwareRAID* >( currentDevice )->status() == SoftwareRAID::Status::Inactive ) + { + cDebug() << Logger::SubEntry << "part of an inactive RAID device"; + isInactiveRAID = true; + } + + PartitionTable::TableType tableType = PartitionTable::unknownTableType; + if ( currentDevice->partitionTable() ) + { + tableType = currentDevice->partitionTable()->type(); + matchTableType = m_config->acceptPartitionTableType( tableType ); + } + + for ( auto it = PartitionIterator::begin( currentDevice ); it != PartitionIterator::end( currentDevice ); ++it ) + { + if ( PartUtils::canBeResized( *it, o ) ) + { + cDebug() << Logger::SubEntry << "contains resizable" << it; + atLeastOneCanBeResized = true; + } + if ( PartUtils::canBeReplaced( *it, o ) ) + { + cDebug() << Logger::SubEntry << "contains replaceable" << it; + atLeastOneCanBeReplaced = true; + } + if ( ( *it )->isMounted() ) + { + atLeastOneIsMounted = true; + } + } + + m_osproberEntriesCount = osproberEntriesForCurrentDevice.count(); + if ( m_osproberEntriesCount == 0 ) + { + m_osproberOneEntryName.clear(); + m_replaceButton->hide(); + m_alongsideButton->hide(); + m_grp->setExclusive( false ); + m_replaceButton->setChecked( false ); + m_alongsideButton->setChecked( false ); + m_grp->setExclusive( true ); + } + else if ( m_osproberEntriesCount == 1 ) + { + m_osproberOneEntryName = osproberEntriesForCurrentDevice.first().prettyName; + } + else + { + // osproberEntriesForCurrentDevice has at least 2 items. + m_osproberOneEntryName.clear(); + } + updateActionDescriptionsTr(); + +#ifdef DEBUG_PARTITION_UNSAFE +#ifdef DEBUG_PARTITION_BAIL_OUT + // If things can't be broken, allow all the buttons + atLeastOneCanBeReplaced = true; + atLeastOneCanBeResized = true; + atLeastOneIsMounted = false; + isInactiveRAID = false; +#endif +#endif + + if ( atLeastOneCanBeReplaced ) + { + m_replaceButton->show(); + } + else + { + cDebug() << "No partitions available for replace-action."; + force_uncheck( m_grp, m_replaceButton ); + } + + if ( atLeastOneCanBeResized ) + { + m_alongsideButton->show(); + } + else + { + cDebug() << "No partitions available for resize-action."; + force_uncheck( m_grp, m_alongsideButton ); + } + + if ( !atLeastOneIsMounted && !isInactiveRAID ) + { + m_eraseButton->show(); // None mounted + } + else + { + cDebug() << "No partitions (" + << "any-mounted?" << atLeastOneIsMounted << "is-raid?" << isInactiveRAID << ") for erase-action."; + force_uncheck( m_grp, m_eraseButton ); + } + + bool isEfi = PartUtils::isEfiSystem(); + bool efiSystemPartitionFound = !m_core->efiSystemPartitions().isEmpty(); + + if ( isEfi && !efiSystemPartitionFound ) + { + cWarning() << "System is EFI but there's no EFI system partition, " + "DISABLING alongside and replace features."; + m_alongsideButton->hide(); + m_replaceButton->hide(); + } + + if ( tableType != PartitionTable::unknownTableType && !matchTableType ) + { + m_messageLabel->setText( tr( "This storage device already has an operating system on it, " + "but the partition table %1 is different from the " + "needed %2.
    " ) + .arg( PartitionTable::tableTypeToName( tableType ) ) + .arg( describePartitionTypes( m_config->partitionTableTypes() ) ) ); + m_messageLabel->show(); + + cWarning() << "Partition table" << PartitionTable::tableTypeToName( tableType ) + << "does not match the requirement " << m_config->partitionTableTypes().join( ',' ) + << ", ENABLING erase feature and DISABLING alongside, replace and manual features."; + m_eraseButton->show(); + m_alongsideButton->hide(); + m_replaceButton->hide(); + m_somethingElseButton->hide(); + cDebug() << "Replace button suppressed because partition table type mismatch."; + force_uncheck( m_grp, m_replaceButton ); + } + + if ( m_somethingElseButton->isHidden() && m_alongsideButton->isHidden() && m_replaceButton->isHidden() + && m_eraseButton->isHidden() ) + { + if ( atLeastOneIsMounted ) + { + m_messageLabel->setText( + tr( "This storage device has one of its partitions mounted.", "@info" ) ); + } + else + { + m_messageLabel->setText( + tr( "This storage device is a part of an inactive RAID device.", "@info" ) ); + } + + m_messageLabel->show(); + cWarning() << "No buttons available" + << "replaced?" << atLeastOneCanBeReplaced << "resized?" << atLeastOneCanBeResized + << "erased? (not-mounted and not-raid)" << !atLeastOneIsMounted << "and" << !isInactiveRAID; + } +} + +OsproberEntryList +ChoicePage::getOsproberEntriesForDevice( Device* device ) const +{ + OsproberEntryList eList; + for ( const OsproberEntry& entry : m_core->osproberEntries() ) + { + if ( entry.path.startsWith( device->deviceNode() ) ) + { + eList.append( entry ); + } + } + return eList; +} + +bool +ChoicePage::isNextEnabled() const +{ + return m_nextEnabled; +} + +bool +ChoicePage::calculateNextEnabled() const +{ + auto sm_p = m_beforePartitionBarsView ? m_beforePartitionBarsView->selectionModel() : nullptr; + + switch ( m_config->installChoice() ) + { + case InstallChoice::NoChoice: + cDebug() << "No partitioning choice has been made yet"; + return false; + case InstallChoice::Replace: + case InstallChoice::Alongside: + if ( !( sm_p && sm_p->currentIndex().isValid() ) ) + { + cDebug() << "No partition selected for alongside or replace"; + return false; + } + break; + case InstallChoice::Erase: + case InstallChoice::Manual: + // Nothing to check for these + break; + } + + if ( m_isEfi + && ( m_config->installChoice() == InstallChoice::Alongside + || m_config->installChoice() == InstallChoice::Replace ) ) + { + if ( m_core->efiSystemPartitions().count() == 0 ) + { + cDebug() << "No EFI partition for alongside or replace"; + return false; + } + } + + // You can have an invisible encryption checkbox, which is + // still checked -- then do the encryption. + if ( m_config->installChoice() != InstallChoice::Manual + && ( m_encryptWidget->isVisible() || m_encryptWidget->isEncryptionCheckboxChecked() ) ) + { + switch ( m_encryptWidget->state() ) + { + case EncryptWidget::Encryption::Unconfirmed: + cDebug() << "No passphrase provided or passphrase mismatch."; + return false; + case EncryptWidget::Encryption::Disabled: + case EncryptWidget::Encryption::Confirmed: + // Checkbox not checked, **or** passphrases match + break; + } + } + + return true; +} + +void +ChoicePage::updateNextEnabled() +{ + bool enabled = calculateNextEnabled(); + + if ( enabled != m_nextEnabled ) + { + m_nextEnabled = enabled; + Q_EMIT nextStatusChanged( enabled ); + } +} + +void +ChoicePage::updateSwapChoicesTr() +{ + if ( !m_eraseSwapChoiceComboBox ) + { + return; + } + + static_assert( SwapChoice::NoSwap == 0, "Enum values out-of-sync" ); + for ( int index = 0; index < m_eraseSwapChoiceComboBox->count(); ++index ) + { + bool ok = false; + int value = 0; + + switch ( value = m_eraseSwapChoiceComboBox->itemData( index ).toInt( &ok ) ) + { + // case 0: + case SwapChoice::NoSwap: + // toInt() returns 0 on failure, so check for ok + if ( ok ) // It was explicitly set to 0 + { + m_eraseSwapChoiceComboBox->setItemText( index, tr( "No swap", "@label" ) ); + } + else + { + cWarning() << "Box item" << index << m_eraseSwapChoiceComboBox->itemText( index ) + << "has non-integer role."; + } + break; + case SwapChoice::ReuseSwap: + m_eraseSwapChoiceComboBox->setItemText( index, tr( "Reuse swap", "@label" ) ); + break; + case SwapChoice::SmallSwap: + m_eraseSwapChoiceComboBox->setItemText( index, tr( "Swap (no Hibernate)", "@label" ) ); + break; + case SwapChoice::FullSwap: + m_eraseSwapChoiceComboBox->setItemText( index, tr( "Swap (with Hibernate)", "@label" ) ); + break; + case SwapChoice::SwapFile: + m_eraseSwapChoiceComboBox->setItemText( index, tr( "Swap to file", "@label" ) ); + break; + default: + cWarning() << "Box item" << index << m_eraseSwapChoiceComboBox->itemText( index ) << "has role" << value; + } + } +} + +void +ChoicePage::updateChoiceButtonsTr() +{ + if ( m_somethingElseButton ) + { + m_somethingElseButton->setText( tr( "Manual partitioning
    " + "You can create or resize partitions yourself." ) ); + } +} + +int +ChoicePage::lastSelectedDeviceIndex() +{ + return m_lastSelectedDeviceIndex; +} + +void +ChoicePage::setLastSelectedDeviceIndex( int index ) +{ + m_lastSelectedDeviceIndex = index; + m_drivesCombo->setCurrentIndex( m_lastSelectedDeviceIndex ); +} + +QWidget* +ChoicePage::createBootloaderPanel() +{ + QWidget* panelWidget = new QWidget; + + QHBoxLayout* mainLayout = new QHBoxLayout; + panelWidget->setLayout( mainLayout ); + mainLayout->setContentsMargins( 0, 0, 0, 0 ); + QLabel* widgetLabel = new QLabel( panelWidget ); + mainLayout->addWidget( widgetLabel ); + widgetLabel->setText( tr( "Bootloader location:", "@label" ) ); + + QComboBox* comboForBootloader = new QComboBox( panelWidget ); + comboForBootloader->setModel( m_core->bootLoaderModel() ); + + // When the chosen bootloader device changes, we update the choice in the PCM + connect( comboForBootloader, + QOverload< int >::of( &QComboBox::currentIndexChanged ), + this, + [ this ]( int newIndex ) + { + QComboBox* bootloaderCombo = qobject_cast< QComboBox* >( sender() ); + if ( bootloaderCombo ) + { + QVariant var = bootloaderCombo->itemData( newIndex, BootLoaderModel::BootLoaderPathRole ); + if ( !var.isValid() ) + { + return; + } + m_core->setBootLoaderInstallPath( var.toString() ); + } + } ); + m_bootloaderComboBox = comboForBootloader; + + connect( m_core->bootLoaderModel(), + &QAbstractItemModel::modelReset, + [ this ]() + { + if ( !m_bootloaderComboBox.isNull() ) + { + Calamares::restoreSelectedBootLoader( *m_bootloaderComboBox, m_core->bootLoaderInstallPath() ); + } + } ); + connect( + m_core, + &PartitionCoreModule::deviceReverted, + this, + [ this ]( Device* ) + { + if ( !m_bootloaderComboBox.isNull() ) + { + if ( m_bootloaderComboBox->model() != m_core->bootLoaderModel() ) + { + m_bootloaderComboBox->setModel( m_core->bootLoaderModel() ); + } + + m_bootloaderComboBox->setCurrentIndex( m_lastSelectedDeviceIndex ); + } + }, + Qt::QueuedConnection ); + // ^ Must be Queued so it's sure to run when the widget is already visible. + + mainLayout->addWidget( m_bootloaderComboBox ); + widgetLabel->setBuddy( m_bootloaderComboBox ); + mainLayout->addStretch(); + + return panelWidget; +} + +bool +ChoicePage::shouldShowEncryptWidget( Config::InstallChoice choice ) const +{ + bool suitableFS = true; + if ( !m_config->allowZfsEncryption() + && ( ( m_eraseFsTypesChoiceComboBox && m_eraseFsTypesChoiceComboBox->isVisible() + && m_eraseFsTypesChoiceComboBox->currentText() == "zfs" ) + || ( m_replaceFsTypesChoiceComboBox && m_replaceFsTypesChoiceComboBox->isVisible() + && m_replaceFsTypesChoiceComboBox->currentText() == "zfs" ) ) ) + { + suitableFS = false; + } + + const bool suitableChoice + = choice == InstallChoice::Erase || choice == InstallChoice::Alongside || choice == InstallChoice::Replace; + return suitableChoice && m_enableEncryptionWidget && suitableFS; +} + +void +ChoicePage::updateActionDescriptionsTr() +{ + if ( m_osproberEntriesCount == 0 ) + { + cDebug() << "Setting texts for 0 osprober entries"; + m_messageLabel->setText( tr( "This storage device does not seem to have an operating system on it. " + "What would you like to do?
    " + "You will be able to review and confirm your choices " + "before any change is made to the storage device." ) ); + + m_eraseButton->setText( tr( "Erase disk
    " + "This will delete all data " + "currently present on the selected storage device." ) ); + + m_alongsideButton->setText( tr( "Install alongside
    " + "The installer will shrink a partition to make room for %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + + m_replaceButton->setText( tr( "Replace a partition
    " + "Replaces a partition with %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + } + if ( m_osproberEntriesCount == 1 ) + { + if ( !m_osproberOneEntryName.isEmpty() ) + { + cDebug() << "Setting texts for 1 non-empty osprober entry"; + m_messageLabel->setText( tr( "This storage device has %1 on it. " + "What would you like to do?
    " + "You will be able to review and confirm your choices " + "before any change is made to the storage device." ) + .arg( m_osproberOneEntryName ) ); + + m_alongsideButton->setText( tr( "Install alongside
    " + "The installer will shrink a partition to make room for %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + + m_eraseButton->setText( tr( "Erase disk
    " + "This will delete all data " + "currently present on the selected storage device." ) ); + + m_replaceButton->setText( tr( "Replace a partition
    " + "Replaces a partition with %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + } + else + { + cDebug() << "Setting texts for 1 empty osprober entry"; + m_messageLabel->setText( tr( "This storage device already has an operating system on it. " + "What would you like to do?
    " + "You will be able to review and confirm your choices " + "before any change is made to the storage device." ) ); + + m_alongsideButton->setText( tr( "Install alongside
    " + "The installer will shrink a partition to make room for %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + + m_eraseButton->setText( tr( "Erase disk
    " + "This will delete all data " + "currently present on the selected storage device." ) ); + + m_replaceButton->setText( tr( "Replace a partition
    " + "Replaces a partition with %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + } + } + if ( m_osproberEntriesCount >= 2 ) + { + cDebug() << "Setting texts for >= 2 osprober entries"; + + m_messageLabel->setText( tr( "This storage device has multiple operating systems on it. " + "What would you like to do?
    " + "You will be able to review and confirm your choices " + "before any change is made to the storage device." ) ); + + m_alongsideButton->setText( tr( "Install alongside
    " + "The installer will shrink a partition to make room for %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + + m_eraseButton->setText( tr( "Erase disk
    " + "This will delete all data " + "currently present on the selected storage device." ) ); + + m_replaceButton->setText( tr( "Replace a partition
    " + "Replaces a partition with %1." ) + .arg( Calamares::Branding::instance()->shortVersionedName() ) ); + } + if ( m_osproberEntriesCount < 0 ) + { + cWarning() << "Invalid osprober count, labels and buttons not updated."; + } +} diff --git a/calamares/src/modules/partition/gui/ChoicePage.h b/calamares/src/modules/partition/gui/ChoicePage.h new file mode 100644 index 0000000..6a777e2 --- /dev/null +++ b/calamares/src/modules/partition/gui/ChoicePage.h @@ -0,0 +1,181 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2023 Evan James + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CHOICEPAGE_H +#define CHOICEPAGE_H + +#include "ui_ChoicePage.h" + + +#include "Config.h" +#include "core/OsproberEntry.h" + +#include +#include +#include +#include + +class QBoxLayout; +class QComboBox; +class QLabel; +class QListView; + +namespace Calamares +{ +namespace Widgets +{ +class PrettyRadioButton; +} // namespace Widgets +} // namespace Calamares + +class Config; +class DeviceInfoWidget; +class PartitionBarsView; +class PartitionSplitterWidget; +class PartitionLabelsView; +class PartitionCoreModule; + +class Device; + +using SwapChoiceSet = Config::SwapChoiceSet; + +/** + * @brief The ChoicePage class is the first page of the partitioning interface. + * It offers a choice between partitioning operations and initiates all automated + * partitioning modes. For manual partitioning, see PartitionPage. + */ +class ChoicePage : public QWidget, private Ui::ChoicePage +{ + Q_OBJECT +public: + explicit ChoicePage( Config* config, QWidget* parent = nullptr ); + ~ChoicePage() override; + + /** + * @brief init runs when the PartitionViewStep and the PartitionCoreModule are + * ready. Sets up the rest of the UI based on os-prober output. + * @param core the PartitionCoreModule pointer. + */ + void init( PartitionCoreModule* core ); + + /** + * @brief isNextEnabled answers whether the current state of the page is such + * that progressing to the next page should be allowed. + * @return true if next is allowed, otherwise false. + */ + bool isNextEnabled() const; + + /** + * @brief onLeave runs when control passes from this page to another one. + */ + void onLeave(); + + /** + * @brief applyActionChoice reacts to a choice of partitioning mode. + * @param choice the partitioning action choice. + */ + void applyActionChoice( Config::InstallChoice choice ); + + int lastSelectedDeviceIndex(); + void setLastSelectedDeviceIndex( int index ); + +signals: + void nextStatusChanged( bool ); + void actionChosen(); + void deviceChosen(); + +private slots: + void onPartitionToReplaceSelected( const QModelIndex& current, const QModelIndex& previous ); + void doReplaceSelectedPartition( const QModelIndex& current ); + void doAlongsideSetupSplitter( const QModelIndex& current, const QModelIndex& previous ); + void onEncryptWidgetStateChanged(); + void onHomeCheckBoxStateChanged(); + + /// @brief Calls applyActionChoice() as needed. + void onActionChanged(); + /// @brief Calls onActionChanged() as needed. + void onEraseSwapChoiceChanged(); + + void retranslate(); + +private: + bool calculateNextEnabled() const; + void updateNextEnabled(); + void setupChoices(); + void checkInstallChoiceRadioButton( Config::InstallChoice choice ); ///< Sets the chosen button to "on" + /** @brief Create a panel with "boot loader location:" + * + * Panel + dropdown and handling for model updates. Returns a pointer + * to the panel's widget. + */ + QWidget* createBootloaderPanel(); + Device* selectedDevice(); + + /* Change the UI depending on the device selected. */ + void hideButtons(); // Hide everything when no device + void applyDeviceChoice(); // Start scanning new device + void continueApplyDeviceChoice(); // .. called after scan + + void updateDeviceStatePreview(); + void updateActionChoicePreview( Config::InstallChoice choice ); + bool shouldShowEncryptWidget( Config::InstallChoice choice ) const; + void setupActions(); + OsproberEntryList getOsproberEntriesForDevice( Device* device ) const; + void doAlongsideApply(); + void setupEfiSystemPartitionSelector(); + + // Translations support + void updateSwapChoicesTr(); + void updateChoiceButtonsTr(); + void updateActionDescriptionsTr(); + + Config* m_config; + bool m_nextEnabled; + PartitionCoreModule* m_core; + + QMutex m_previewsMutex; + + bool m_isEfi; + QComboBox* m_drivesCombo; + + QButtonGroup* m_grp; + Calamares::Widgets::PrettyRadioButton* m_alongsideButton; + Calamares::Widgets::PrettyRadioButton* m_eraseButton; + Calamares::Widgets::PrettyRadioButton* m_replaceButton; + Calamares::Widgets::PrettyRadioButton* m_somethingElseButton; + QComboBox* m_eraseSwapChoiceComboBox = nullptr; // UI, see also Config's swap choice + QComboBox* m_eraseFsTypesChoiceComboBox = nullptr; // UI, see also Config's erase-mode FS + QComboBox* m_replaceFsTypesChoiceComboBox = nullptr; // UI, see also Config's erase-mode FS + + + DeviceInfoWidget* m_deviceInfoWidget; + + QPointer< PartitionBarsView > m_beforePartitionBarsView; + QPointer< PartitionLabelsView > m_beforePartitionLabelsView; + QPointer< PartitionBarsView > m_afterPartitionBarsView; + QPointer< PartitionLabelsView > m_afterPartitionLabelsView; + QPointer< PartitionSplitterWidget > m_afterPartitionSplitterWidget; + QPointer< QComboBox > m_bootloaderComboBox; + QPointer< QLabel > m_efiLabel; + QPointer< QComboBox > m_efiComboBox; + + int m_lastSelectedDeviceIndex = -1; + int m_osproberEntriesCount = -1; + QString m_osproberOneEntryName; + + bool m_enableEncryptionWidget = false; + bool m_preCheckActivated = false; + + QMutex m_coreMutex; +}; + +#endif // CHOICEPAGE_H diff --git a/calamares/src/modules/partition/gui/ChoicePage.ui b/calamares/src/modules/partition/gui/ChoicePage.ui new file mode 100644 index 0000000..baceba0 --- /dev/null +++ b/calamares/src/modules/partition/gui/ChoicePage.ui @@ -0,0 +1,224 @@ + + + +SPDX-FileCopyrightText: 2015 Teo Mrnjavac <teo@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + ChoicePage + + + + 0 + 0 + 743 + 512 + + + + Form + + + + 0 + + + + + + + + + + <m_drivesLabel> + + + + + + + + + + + + + + <m_messageLabel> + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + 0 + + + true + + + + + 0 + 0 + 729 + 233 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + QFrame::HLine + + + QFrame::Raised + + + + + + + + + + <m_reuseHomeCheckBox> + + + + + + + + + + + + + + 0 + + + + + 0 + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + + + + + + After: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + 0 + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + + + + + + Before: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + + + + + + EncryptWidget + QWidget +
    gui/EncryptWidget.h
    + 1 +
    +
    + + +
    diff --git a/calamares/src/modules/partition/gui/CreatePartitionDialog.cpp b/calamares/src/modules/partition/gui/CreatePartitionDialog.cpp new file mode 100644 index 0000000..213f5c7 --- /dev/null +++ b/calamares/src/modules/partition/gui/CreatePartitionDialog.cpp @@ -0,0 +1,367 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 2020, Adriaan de Groot + * SPDX-FileCopyrightText: 2018 Andrius Štikonas + * SPDX-FileCopyrightText: 2018 Caio Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CreatePartitionDialog.h" +#include "ui_CreatePartitionDialog.h" + +#include "core/ColorUtils.h" +#include "core/KPMHelpers.h" +#include "core/PartUtils.h" +#include "core/PartitionInfo.h" +#include "core/PartitionCoreModule.h" +#include "gui/PartitionDialogHelpers.h" +#include "gui/PartitionSizeController.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "partition/FileSystem.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using Calamares::Partition::untranslatedFS; +using Calamares::Partition::userVisibleFS; + +CreatePartitionDialog::CreatePartitionDialog( PartitionCoreModule* core, + Device* device, + PartitionNode* parentPartition, + const QStringList& usedMountPoints, + QWidget* parentWidget ) + : QDialog( parentWidget ) + , m_ui( new Ui_CreatePartitionDialog ) + , m_core( core ) + , m_partitionSizeController( new PartitionSizeController( this ) ) + , m_device( device ) + , m_parent( parentPartition ) + , m_usedMountPoints( usedMountPoints ) +{ + m_ui->setupUi( this ); + m_ui->encryptWidget->setText( tr( "En&crypt", "@action" ) ); + m_ui->encryptWidget->hide(); + + if ( m_device->type() != Device::Type::LVM_Device ) + { + m_ui->lvNameLabel->hide(); + m_ui->lvNameLineEdit->hide(); + } + if ( m_device->type() == Device::Type::LVM_Device ) + { + /* LVM logical volume name can consist of: letters numbers _ . - + + * It cannot start with underscore _ and must not be equal to . or .. or any entry in /dev/ + * QLineEdit accepts QValidator::Intermediate, so we just disable . at the beginning */ + QRegularExpression re( QStringLiteral( R"(^(?!_|\.)[\w\-.+]+)" ) ); + QRegularExpressionValidator* validator = new QRegularExpressionValidator( re, this ); + m_ui->lvNameLineEdit->setValidator( validator ); + } + + if ( KPMHelpers::isMSDOSPartition( device->partitionTable()->type() ) ) + { + initMbrPartitionTypeUi(); + } + else + { + initGptPartitionTypeUi(); + } + + // File system; the config value is translated (best-effort) to a type + FileSystem::Type defaultFSType; + QString untranslatedFSName = PartUtils::canonicalFilesystemName( + Calamares::JobQueue::instance()->globalStorage()->value( "defaultFileSystemType" ).toString(), &defaultFSType ); + if ( defaultFSType == FileSystem::Type::Unknown ) + { + defaultFSType = FileSystem::Type::Ext4; + } + + int defaultFsIndex = -1; + int fsCounter = 0; + QStringList fsNames; + for ( auto fs : FileSystemFactory::map() ) + { + // We need to ensure zfs is added to the list if the zfs module is enabled + if ( ( fs->type() == FileSystem::Type::Zfs && Calamares::Settings::instance()->isModuleEnabled( "zfs" ) ) + || ( fs->supportCreate() != FileSystem::cmdSupportNone && fs->type() != FileSystem::Extended ) ) + { + fsNames << userVisibleFS( fs ); // This is put into the combobox + if ( fs->type() == defaultFSType ) + { + defaultFsIndex = fsCounter; + } + fsCounter++; + } + } + m_ui->fsComboBox->addItems( fsNames ); + + // Connections + connect( m_ui->fsComboBox, SIGNAL( activated( int ) ), SLOT( updateMountPointUi() ) ); + connect( m_ui->extendedRadioButton, SIGNAL( toggled( bool ) ), SLOT( updateMountPointUi() ) ); + + connect( m_ui->mountPointComboBox, + &QComboBox::currentTextChanged, + this, + &CreatePartitionDialog::checkMountPointSelection ); + + connect( m_ui->fsComboBox, + &QComboBox::currentTextChanged, + this, + &CreatePartitionDialog::checkMountPointSelection ); + + // Select a default + m_ui->fsComboBox->setCurrentIndex( defaultFsIndex ); + updateMountPointUi(); + checkMountPointSelection(); +} + +CreatePartitionDialog::CreatePartitionDialog( PartitionCoreModule* core, + Device* device, + const FreeSpace& freeSpacePartition, + const QStringList& usedMountPoints, + QWidget* parentWidget ) + : CreatePartitionDialog( core, device, freeSpacePartition.p->parent(), usedMountPoints, parentWidget ) +{ + standardMountPoints( *( m_ui->mountPointComboBox ), QString() ); + setFlagList( *( m_ui->m_listFlags ), + static_cast< PartitionTable::Flags >( ~PartitionTable::Flags::Int( 0 ) ), + PartitionTable::Flags() ); + initPartResizerWidget( freeSpacePartition.p ); +} + +CreatePartitionDialog::CreatePartitionDialog( PartitionCoreModule* core, + Device* device, + const FreshPartition& existingNewPartition, + const QStringList& usedMountPoints, + QWidget* parentWidget ) + : CreatePartitionDialog( core, device, existingNewPartition.p->parent(), usedMountPoints, parentWidget ) +{ + standardMountPoints( *( m_ui->mountPointComboBox ), PartitionInfo::mountPoint( existingNewPartition.p ) ); + setFlagList( *( m_ui->m_listFlags ), + static_cast< PartitionTable::Flags >( ~PartitionTable::Flags::Int( 0 ) ), + PartitionInfo::flags( existingNewPartition.p ) ); + + const bool isExtended = existingNewPartition.p->roles().has( PartitionRole::Extended ); + if ( isExtended ) + { + cDebug() << "Editing extended partitions is not supported."; + return; + } + + initPartResizerWidget( existingNewPartition.p ); + + FileSystem::Type fsType = existingNewPartition.p->fileSystem().type(); + m_ui->fsComboBox->setCurrentText( FileSystem::nameForType( fsType ) ); + + setSelectedMountPoint( m_ui->mountPointComboBox, PartitionInfo::mountPoint( existingNewPartition.p ) ); + updateMountPointUi(); +} + +CreatePartitionDialog::~CreatePartitionDialog() {} + + +PartitionTable::Flags +CreatePartitionDialog::newFlags() const +{ + return flagsFromList( *( m_ui->m_listFlags ) ); +} + +void +CreatePartitionDialog::initMbrPartitionTypeUi() +{ + QString fixedPartitionString; + bool parentIsPartitionTable = m_parent->isRoot(); + if ( !parentIsPartitionTable ) + { + m_role = PartitionRole( PartitionRole::Logical ); + fixedPartitionString = tr( "Logical", "@label" ); + } + else if ( m_device->partitionTable()->hasExtended() ) + { + m_role = PartitionRole( PartitionRole::Primary ); + fixedPartitionString = tr( "Primary", "@label" ); + } + + if ( fixedPartitionString.isEmpty() ) + { + m_ui->fixedPartitionLabel->hide(); + } + else + { + m_ui->fixedPartitionLabel->setText( fixedPartitionString ); + m_ui->primaryRadioButton->hide(); + m_ui->extendedRadioButton->hide(); + } +} + +void +CreatePartitionDialog::initGptPartitionTypeUi() +{ + m_role = PartitionRole( PartitionRole::Primary ); + m_ui->fixedPartitionLabel->setText( tr( "GPT", "@label" ) ); + m_ui->primaryRadioButton->hide(); + m_ui->extendedRadioButton->hide(); +} + +Partition* +CreatePartitionDialog::getNewlyCreatedPartition() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + if ( m_role.roles() == PartitionRole::None ) + { + m_role = PartitionRole( m_ui->extendedRadioButton->isChecked() ? PartitionRole::Extended + : PartitionRole::Primary ); + } + + qint64 first = m_partitionSizeController->firstSector(); + qint64 last = m_partitionSizeController->lastSector(); + + FileSystem::Type fsType = m_role.has( PartitionRole::Extended ) + ? FileSystem::Extended + : FileSystem::typeForName( m_ui->fsComboBox->currentText() ); + const QString fsLabel = m_ui->filesystemLabelEdit->text(); + + // The newly-created partitions have no flags set (no **active** flags), + // because they're new. The desired flags can be retrieved from + // newFlags() and the consumer (see PartitionPage::onCreateClicked) + // does so, to set up the partition for create-and-then-set-flags. + Partition* partition = nullptr; + QString luksFsType = gs->value( "luksFileSystemType" ).toString(); + QString luksPassphrase = m_ui->encryptWidget->passphrase(); + if ( m_ui->encryptWidget->state() == EncryptWidget::Encryption::Confirmed && !luksPassphrase.isEmpty() + && fsType != FileSystem::Zfs ) + { + partition = KPMHelpers::createNewEncryptedPartition( + m_parent, + *m_device, + m_role, + fsType, + fsLabel, + first, + last, + Config::luksGenerationNames().find( luksFsType, Config::LuksGeneration::Luks1 ), + luksPassphrase, + PartitionTable::Flags() ); + } + else + { + partition = KPMHelpers::createNewPartition( + m_parent, *m_device, m_role, fsType, fsLabel, first, last, PartitionTable::Flags() ); + } + + // For zfs, we let the zfs module handle the encryption but we need to make the passphrase available to later modules + if ( fsType == FileSystem::Zfs ) + { + Calamares::GlobalStorage* storage = Calamares::JobQueue::instance()->globalStorage(); + QList< QVariant > zfsInfoList; + QVariantMap zfsInfo; + + // If this is not the first encrypted zfs partition, get the old list first + if ( storage->contains( "zfsInfo" ) ) + { + zfsInfoList = storage->value( "zfsInfo" ).toList(); + storage->remove( "zfsInfo" ); + } + + // Save the information subsequent modules will need + zfsInfo[ "encrypted" ] + = m_ui->encryptWidget->state() == EncryptWidget::Encryption::Confirmed && !luksPassphrase.isEmpty(); + zfsInfo[ "passphrase" ] = luksPassphrase; + zfsInfo[ "mountpoint" ] = selectedMountPoint( m_ui->mountPointComboBox ); + + // Add it to the list and insert it into global storage + zfsInfoList.append( zfsInfo ); + storage->insert( "zfsInfo", zfsInfoList ); + } + + if ( m_device->type() == Device::Type::LVM_Device ) + { + partition->setPartitionPath( m_device->deviceNode() + QStringLiteral( "/" ) + + m_ui->lvNameLineEdit->text().trimmed() ); + } + + PartitionInfo::setMountPoint( partition, selectedMountPoint( m_ui->mountPointComboBox ) ); + PartitionInfo::setFormat( partition, true ); + + return partition; +} + +void +CreatePartitionDialog::updateMountPointUi() +{ + bool enabled = m_ui->primaryRadioButton->isChecked(); + if ( enabled ) + { + // This maps translated (user-visible) FS names to a type + FileSystem::Type type = FileSystem::typeForName( m_ui->fsComboBox->currentText() ); + enabled = !s_unmountableFS.contains( type ); + + if ( FileSystemFactory::map()[ FileSystem::Type::Luks ]->supportCreate() && FS::luks::canEncryptType( type ) + && !m_role.has( PartitionRole::Extended ) ) + { + m_ui->encryptWidget->show(); + m_ui->encryptWidget->reset(); + } + else if ( FileSystemFactory::map()[ FileSystem::Type::Luks2 ]->supportCreate() + && FS::luks2::canEncryptType( type ) && !m_role.has( PartitionRole::Extended ) ) + { + m_ui->encryptWidget->show(); + m_ui->encryptWidget->reset(); + } + else + { + m_ui->encryptWidget->reset(); + m_ui->encryptWidget->hide(); + } + } + m_ui->mountPointLabel->setEnabled( enabled ); + m_ui->mountPointComboBox->setEnabled( enabled ); + if ( !enabled ) + { + m_ui->mountPointComboBox->setCurrentText( QString() ); + } +} + +void +CreatePartitionDialog::checkMountPointSelection() +{ + validateMountPoint( m_core, + selectedMountPoint( m_ui->mountPointComboBox ), + m_usedMountPoints, + m_ui->fsComboBox->currentText(), + m_ui->mountPointExplanation, + m_ui->buttonBox->button( QDialogButtonBox::Ok ) ); +} + +void +CreatePartitionDialog::initPartResizerWidget( Partition* partition ) +{ + QColor color = Calamares::Partition::isPartitionFreeSpace( partition ) + ? ColorUtils::colorForPartitionInFreeSpace( partition ) + : ColorUtils::colorForPartition( partition ); + m_partitionSizeController->init( m_device, partition, color ); + m_partitionSizeController->setPartResizerWidget( m_ui->partResizerWidget ); + m_partitionSizeController->setSpinBox( m_ui->sizeSpinBox ); +} diff --git a/calamares/src/modules/partition/gui/CreatePartitionDialog.h b/calamares/src/modules/partition/gui/CreatePartitionDialog.h new file mode 100644 index 0000000..75a0da0 --- /dev/null +++ b/calamares/src/modules/partition/gui/CreatePartitionDialog.h @@ -0,0 +1,102 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CREATEPARTITIONDIALOG_H +#define CREATEPARTITIONDIALOG_H + +// KPMcore +#include +#include + +#include +#include + +class PartitionCoreModule; +class Device; +class Partition; +class PartitionNode; +class PartitionSizeController; +class Ui_CreatePartitionDialog; + +/** + * The dialog which is shown to create a new partition or to edit a + * to-be-created partition. + */ +class CreatePartitionDialog : public QDialog +{ + Q_OBJECT + +private: + /** @brief Delegated constructor + * + * This does all the shared UI setup. + */ + CreatePartitionDialog( PartitionCoreModule* core, + Device* device, + PartitionNode* parentPartition, + const QStringList& usedMountPoints, + QWidget* parentWidget ); + +public: + struct FreeSpace + { + Partition* p; + }; + struct FreshPartition + { + Partition* p; + }; + + /** @brief Dialog for editing a new partition based on free space. + * + * Creating from free space makes a wholly new partition with + * no flags set at all. + */ + CreatePartitionDialog( PartitionCoreModule* core, + Device* device, + const FreeSpace& freeSpacePartition, + const QStringList& usedMountPoints, + QWidget* parentWidget = nullptr ); + /** @brief Dialog for editing a newly-created partition. + * + * A partition previously newly created (e.g. via this dialog + * and the constructor above) can be re-edited. + */ + CreatePartitionDialog( PartitionCoreModule* core, + Device* device, + const FreshPartition& existingNewPartition, + const QStringList& usedMountPoints, + QWidget* parentWidget = nullptr ); + ~CreatePartitionDialog() override; + + Partition* getNewlyCreatedPartition(); + + PartitionTable::Flags newFlags() const; + +private Q_SLOTS: + void updateMountPointUi(); + void checkMountPointSelection(); + +private: + QScopedPointer< Ui_CreatePartitionDialog > m_ui; + PartitionCoreModule* m_core; + PartitionSizeController* m_partitionSizeController; + Device* m_device; + PartitionNode* m_parent; + PartitionRole m_role = PartitionRole( PartitionRole::None ); + QStringList m_usedMountPoints; + + void initGptPartitionTypeUi(); + void initMbrPartitionTypeUi(); + void initPartResizerWidget( Partition* ); +}; + +#endif /* CREATEPARTITIONDIALOG_H */ diff --git a/calamares/src/modules/partition/gui/CreatePartitionDialog.ui b/calamares/src/modules/partition/gui/CreatePartitionDialog.ui new file mode 100644 index 0000000..0ee715f --- /dev/null +++ b/calamares/src/modules/partition/gui/CreatePartitionDialog.ui @@ -0,0 +1,344 @@ + + + +SPDX-FileCopyrightText: 2014 Aurélien Gâteau <agateau@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + CreatePartitionDialog + + + + 0 + 0 + 763 + 689 + + + + Create a Partition + + + + + + + 0 + 0 + + + + + 0 + 59 + + + + + + + + + + Si&ze: + + + sizeSpinBox + + + + + + + MiB + + + + + + + Partition &Type: + + + primaryRadioButton + + + + + + + + + Primar&y + + + true + + + + + + + E&xtended + + + + + + + [fixed-partition-label] + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 13 + + + + + + + + Fi&le System: + + + fsComboBox + + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 13 + + + + + + + + LVM LV name + + + + + + + + + + &Mount Point: + + + mountPointComboBox + + + + + + + + 0 + 0 + + + + true + + + -1 + + + + + + + Flags: + + + + + + + true + + + QAbstractItemView::NoSelection + + + true + + + + + + + Qt::Vertical + + + + 17 + 13 + + + + + + + + Label for the filesystem + + + 16 + + + + + + + FS Label: + + + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + PartResizerWidget + QWidget +
    kpmcore/gui/partresizerwidget.h
    + 1 +
    + + EncryptWidget + QWidget +
    gui/EncryptWidget.h
    + 1 +
    +
    + + primaryRadioButton + fsComboBox + + + + + buttonBox + accepted() + CreatePartitionDialog + accept() + + + 185 + 203 + + + 157 + 178 + + + + + buttonBox + rejected() + CreatePartitionDialog + reject() + + + 185 + 203 + + + 243 + 178 + + + + + extendedRadioButton + toggled(bool) + fsComboBox + setDisabled(bool) + + + 131 + 36 + + + 134 + 66 + + + + + extendedRadioButton + toggled(bool) + label_2 + setDisabled(bool) + + + 109 + 43 + + + 79 + 64 + + + + +
    diff --git a/calamares/src/modules/partition/gui/CreatePartitionTableDialog.ui b/calamares/src/modules/partition/gui/CreatePartitionTableDialog.ui new file mode 100644 index 0000000..4f9fe59 --- /dev/null +++ b/calamares/src/modules/partition/gui/CreatePartitionTableDialog.ui @@ -0,0 +1,142 @@ + + + +SPDX-FileCopyrightText: 2014 Aurélien Gâteau <agateau@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + CreatePartitionTableDialog + + + + 0 + 0 + 297 + 182 + + + + + 0 + 0 + + + + Create Partition Table + + + + + + + 75 + true + + + + [are-you-sure-message] + + + + + + + Creating a new partition table will delete all existing data on the disk. + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 24 + + + + + + + + What kind of partition table do you want to create? + + + + + + + Master Boot Record (MBR) + + + true + + + + + + + GUID Partition Table (GPT) + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + mbrRadioButton + gptRadioButton + buttonBox + + + + + buttonBox + accepted() + CreatePartitionTableDialog + accept() + + + 222 + 141 + + + 157 + 155 + + + + + buttonBox + rejected() + CreatePartitionTableDialog + reject() + + + 290 + 147 + + + 286 + 155 + + + + + diff --git a/calamares/src/modules/partition/gui/CreateVolumeGroupDialog.cpp b/calamares/src/modules/partition/gui/CreateVolumeGroupDialog.cpp new file mode 100644 index 0000000..3d13687 --- /dev/null +++ b/calamares/src/modules/partition/gui/CreateVolumeGroupDialog.cpp @@ -0,0 +1,47 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CreateVolumeGroupDialog.h" + +#include +#include + +#include +#include +#include + +CreateVolumeGroupDialog::CreateVolumeGroupDialog( QString& vgName, + QVector< const Partition* >& selectedPVs, + QVector< const Partition* > pvList, + qint64& pSize, + QWidget* parent ) + : VolumeGroupBaseDialog( vgName, pvList, parent ) + , m_selectedPVs( selectedPVs ) + , m_peSize( pSize ) +{ + setWindowTitle( tr( "Create Volume Group", "@title" ) ); + + peSize()->setValue( pSize ); + + vgType()->setEnabled( false ); +} + +void +CreateVolumeGroupDialog::accept() +{ + QString& name = vgNameValue(); + name = vgName()->text(); + + m_selectedPVs << checkedItems(); + + qint64& pe = m_peSize; + pe = peSize()->value(); + + QDialog::accept(); +} diff --git a/calamares/src/modules/partition/gui/CreateVolumeGroupDialog.h b/calamares/src/modules/partition/gui/CreateVolumeGroupDialog.h new file mode 100644 index 0000000..4712a91 --- /dev/null +++ b/calamares/src/modules/partition/gui/CreateVolumeGroupDialog.h @@ -0,0 +1,33 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CREATEVOLUMEGROUPDIALOG_H +#define CREATEVOLUMEGROUPDIALOG_H + +#include "gui/VolumeGroupBaseDialog.h" + +class CreateVolumeGroupDialog : public VolumeGroupBaseDialog +{ + Q_OBJECT +public: + CreateVolumeGroupDialog( QString& vgName, + QVector< const Partition* >& selectedPVs, + QVector< const Partition* > pvList, + qint64& pSize, + QWidget* parent ); + + void accept() override; + +private: + QVector< const Partition* >& m_selectedPVs; + + qint64& m_peSize; +}; + +#endif // CREATEVOLUMEGROUPDIALOG_H diff --git a/calamares/src/modules/partition/gui/DeviceInfoWidget.cpp b/calamares/src/modules/partition/gui/DeviceInfoWidget.cpp new file mode 100644 index 0000000..628560f --- /dev/null +++ b/calamares/src/modules/partition/gui/DeviceInfoWidget.cpp @@ -0,0 +1,163 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "DeviceInfoWidget.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/QtCompat.h" +#include "utils/Retranslator.h" + +#include +#include +#include + +DeviceInfoWidget::DeviceInfoWidget( QWidget* parent ) + : QWidget( parent ) + , m_ptIcon( new QLabel ) + , m_ptLabel( new QLabel ) + , m_tableType( PartitionTable::unknownTableType ) +{ + QHBoxLayout* mainLayout = new QHBoxLayout; + setLayout( mainLayout ); + + Calamares::unmarginLayout( mainLayout ); + m_ptLabel->setObjectName( "deviceInfoLabel" ); + m_ptIcon->setObjectName( "deviceInfoIcon" ); + mainLayout->addWidget( m_ptIcon ); + mainLayout->addWidget( m_ptLabel ); + + QSize iconSize = Calamares::defaultIconSize(); + + m_ptIcon->setMargin( 0 ); + m_ptIcon->setFixedSize( iconSize ); + m_ptIcon->setPixmap( Calamares::defaultPixmap( Calamares::PartitionTable, Calamares::Original, iconSize ) ); + + QFontMetrics fm = QFontMetrics( QFont() ); + m_ptLabel->setMinimumWidth( fm.boundingRect( "Amiga" ).width() + Calamares::defaultFontHeight() / 2 ); + m_ptLabel->setAlignment( Qt::AlignCenter ); + + QPalette palette; + palette.setBrush( WindowText, QColor( "#4D4D4D" ) ); //dark grey + + m_ptIcon->setAutoFillBackground( true ); + m_ptLabel->setAutoFillBackground( true ); + m_ptIcon->setPalette( palette ); + m_ptLabel->setPalette( palette ); + + CALAMARES_RETRANSLATE_SLOT( &DeviceInfoWidget::retranslateUi ); +} + +void +DeviceInfoWidget::setPartitionTableType( PartitionTable::TableType type ) +{ + m_tableType = type; + retranslateUi(); +} + +void +DeviceInfoWidget::retranslateUi() +{ + QString typeString; + QString toolTipString; + + // fix up if the name shouldn't be uppercase: + switch ( m_tableType ) + { + case PartitionTable::msdos: +#if WITH_KPMcore > 0x240801 + // Pick your warning: either deprecation warning, or unchecked enum-switch +QT_WARNING_PUSH +QT_WARNING_DISABLE_DEPRECATED +#endif + case PartitionTable::msdos_sectorbased: +#if WITH_KPMcore > 0x240801 +QT_WARNING_POP +#endif + typeString = "MBR"; + toolTipString += tr( "

    This partition table type is only advisable on older " + "systems which start from a BIOS boot " + "environment. GPT is recommended in most other cases.

    " + "Warning: the MBR partition table " + "is an obsolete MS-DOS era standard.
    " + "Only 4 primary partitions may be created, and of " + "those 4, one can be an extended partition, which " + "may in turn contain many logical partitions." ); + break; + case PartitionTable::gpt: + // TypeString is ok + toolTipString += tr( "

    This is the recommended partition table type for modern " + "systems which start from an EFI boot " + "environment." ); + break; + case PartitionTable::loop: + typeString = "loop"; + toolTipString = tr( "This is a loop " + "device.

    " + "It is a pseudo-device with no partition table " + "that makes a file accessible as a block device. " + "This kind of setup usually only contains a single filesystem." ); + break; + case PartitionTable::none: + case PartitionTable::unknownTableType: + typeString = " ? "; + toolTipString = tr( "This installer cannot detect a partition table on the " + "selected storage device.

    " + "The device either has no partition " + "table, or the partition table is corrupted or of an unknown " + "type.
    " + "This installer can create a new partition table for you, " + "either automatically, or through the manual partitioning " + "page." ); + break; + // The next ones need to have the name adjusted, but the default tooltip is OK + case PartitionTable::mac: + typeString = "Mac"; + break; + case PartitionTable::amiga: + typeString = "Amiga"; + break; + case PartitionTable::sun: + typeString = "Sun"; + break; + // Peculiar tables, do nothing and use default type and tooltip strings + case PartitionTable::aix: + case PartitionTable::bsd: + case PartitionTable::dasd: + case PartitionTable::dvh: + case PartitionTable::pc98: + case PartitionTable::vmd: + break; + } + + if ( typeString.isEmpty() ) + { + typeString = PartitionTable::tableTypeToName( m_tableType ).toUpper(); + } + if ( toolTipString.isEmpty() ) + { + toolTipString = tr( "This device has a %1 partition " + "table." ) + .arg( typeString ); + } + + m_ptLabel->setText( typeString ); + m_ptLabel->setToolTip( toolTipString ); + + m_ptIcon->setToolTip( tr( "The type of partition table on the " + "selected storage device.

    " + "The only way to change the partition table type is to " + "erase and recreate the partition table from scratch, " + "which destroys all data on the storage device.
    " + "This installer will keep the current partition table " + "unless you explicitly choose otherwise.
    " + "If unsure, on modern systems GPT is preferred." ) ); +} diff --git a/calamares/src/modules/partition/gui/DeviceInfoWidget.h b/calamares/src/modules/partition/gui/DeviceInfoWidget.h new file mode 100644 index 0000000..a69251b --- /dev/null +++ b/calamares/src/modules/partition/gui/DeviceInfoWidget.h @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + + +#ifndef DEVICEINFOWIDGET_H +#define DEVICEINFOWIDGET_H + +#include + +#include + +class QLabel; + +class DeviceInfoWidget : public QWidget +{ + Q_OBJECT +public: + explicit DeviceInfoWidget( QWidget* parent = nullptr ); + + void setPartitionTableType( PartitionTable::TableType type ); + +public slots: + void retranslateUi(); + +private: + QLabel* m_ptIcon; + QLabel* m_ptLabel; + PartitionTable::TableType m_tableType; +}; + +#endif // DEVICEINFOWIDGET_H diff --git a/calamares/src/modules/partition/gui/EditExistingPartitionDialog.cpp b/calamares/src/modules/partition/gui/EditExistingPartitionDialog.cpp new file mode 100644 index 0000000..2b9b940 --- /dev/null +++ b/calamares/src/modules/partition/gui/EditExistingPartitionDialog.cpp @@ -0,0 +1,393 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2008-2009 Volker Lanz + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Andrius Štikonas + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Flags handling originally from KDE Partition Manager. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "EditExistingPartitionDialog.h" +#include "ui_EditExistingPartitionDialog.h" + +#include "core/ColorUtils.h" +#include "core/KPMHelpers.h" +#include "core/PartUtils.h" +#include "core/PartitionCoreModule.h" +#include "core/PartitionInfo.h" +#include "gui/PartitionDialogHelpers.h" +#include "gui/PartitionSizeController.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "partition/FileSystem.h" +#include "utils/Logger.h" +#include "widgets/TranslationFix.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using Calamares::Partition::untranslatedFS; +using Calamares::Partition::userVisibleFS; + +static void +updateLabel( PartitionCoreModule* core, Device* device, Partition* partition, const QString& fsLabel ) +{ + // In this case, we are not formatting the partition, but we are setting the + // label on the current filesystem, if any. We only create the job if the + // label actually changed. + if ( partition->fileSystem().type() != FileSystem::Type::Unformatted && fsLabel != partition->fileSystem().label() ) + { + core->setFilesystemLabel( device, partition, fsLabel ); + } +} + +EditExistingPartitionDialog::EditExistingPartitionDialog( PartitionCoreModule* core, + Device* device, + Partition* partition, + const QStringList& usedMountPoints, + QWidget* parentWidget ) + : QDialog( parentWidget ) + , m_ui( new Ui_EditExistingPartitionDialog ) + , m_core( core ) + , m_device( device ) + , m_partition( partition ) + , m_partitionSizeController( new PartitionSizeController( this ) ) + , m_usedMountPoints( usedMountPoints ) +{ + m_ui->setupUi( this ); + m_ui->encryptWidget->hide(); + standardMountPoints( *( m_ui->mountPointComboBox ), PartitionInfo::mountPoint( partition ) ); + + QColor color = ColorUtils::colorForPartition( m_partition ); + m_partitionSizeController->init( m_device, m_partition, color ); + m_partitionSizeController->setSpinBox( m_ui->sizeSpinBox ); + + connect( m_ui->mountPointComboBox, + &QComboBox::currentTextChanged, + this, + &EditExistingPartitionDialog::checkMountPointSelection ); + + connect( m_ui->fileSystemComboBox, + &QComboBox::currentTextChanged, + this, + &EditExistingPartitionDialog::checkMountPointSelection ); + + // The filesystem label field is always enabled, because we may want to change + // the label on the current filesystem without formatting. + m_ui->fileSystemLabelEdit->setText( PartitionInfo::label( m_partition ) ); + m_ui->fileSystemLabel->setEnabled( true ); + + replacePartResizerWidget(); + + connect( m_ui->formatRadioButton, + &QAbstractButton::toggled, + [ this ]( bool doFormat ) + { + replacePartResizerWidget(); + + m_ui->fileSystemComboBox->setEnabled( doFormat ); + + if ( !doFormat ) + { + m_ui->fileSystemComboBox->setCurrentText( userVisibleFS( m_partition->fileSystem() ) ); + } + + updateMountPointPicker(); + } ); + + connect( + m_ui->fileSystemComboBox, &QComboBox::currentTextChanged, [ this ]( QString ) { updateMountPointPicker(); } ); + + // File system + QStringList fsNames; + for ( auto fs : FileSystemFactory::map() ) + { + // We need to ensure zfs is added to the list if the zfs module is enabled + if ( ( fs->type() == FileSystem::Type::Zfs && Calamares::Settings::instance()->isModuleEnabled( "zfs" ) ) + || ( fs->supportCreate() != FileSystem::cmdSupportNone && fs->type() != FileSystem::Extended ) ) + { + fsNames << userVisibleFS( fs ); // For the combo box + } + } + m_ui->fileSystemComboBox->addItems( fsNames ); + + FileSystem::Type defaultFSType; + QString untranslatedFSName = PartUtils::canonicalFilesystemName( + Calamares::JobQueue::instance()->globalStorage()->value( "defaultFileSystemType" ).toString(), &defaultFSType ); + if ( defaultFSType == FileSystem::Type::Unknown ) + { + defaultFSType = FileSystem::Type::Ext4; + } + + QString thisFSNameForUser = userVisibleFS( m_partition->fileSystem() ); + if ( fsNames.contains( thisFSNameForUser ) ) + { + m_ui->fileSystemComboBox->setCurrentText( thisFSNameForUser ); + } + else + { + m_ui->fileSystemComboBox->setCurrentText( FileSystem::nameForType( defaultFSType ) ); + } + + // Force a format if the existing device is a zfs device since reusing a + // zpool isn't currently supported; disable the radio buttons then. + const bool partitionIsZFS = m_partition->fileSystem().type() == FileSystem::Type::Zfs; + m_ui->formatRadioButton->setEnabled( !partitionIsZFS ); + m_ui->keepRadioButton->setEnabled( !partitionIsZFS ); + + const bool formatChecked = partitionIsZFS || PartitionInfo::format( m_partition ); + m_ui->formatRadioButton->setChecked( formatChecked ); + m_ui->keepRadioButton->setChecked( !formatChecked ); + + m_ui->fileSystemComboBox->setEnabled( m_ui->formatRadioButton->isChecked() ); + + setFlagList( *( m_ui->m_listFlags ), m_partition->availableFlags(), PartitionInfo::flags( m_partition ) ); +} + +EditExistingPartitionDialog::~EditExistingPartitionDialog() {} + +PartitionTable::Flags +EditExistingPartitionDialog::newFlags() const +{ + return flagsFromList( *( m_ui->m_listFlags ) ); +} + +void +EditExistingPartitionDialog::applyChanges( PartitionCoreModule* core ) +{ + // Remove jobs that we might have created for this partition already, + // and also clear intentions so that we set the current ones unconditionally. + core->clearJobs( m_device, m_partition ); + PartitionInfo::reset( m_partition ); + + const QString mountPoint = selectedMountPoint( m_ui->mountPointComboBox ); + PartitionInfo::setMountPoint( m_partition, mountPoint ); + + qint64 newFirstSector = m_partitionSizeController->firstSector(); + qint64 newLastSector = m_partitionSizeController->lastSector(); + bool partResizedMoved = newFirstSector != m_partition->firstSector() || newLastSector != m_partition->lastSector(); + + FileSystem::Type fsType = FileSystem::Unknown; + if ( m_ui->formatRadioButton->isChecked() ) + { + fsType = m_partition->roles().has( PartitionRole::Extended ) + ? FileSystem::Extended + : FileSystem::typeForName( m_ui->fileSystemComboBox->currentText() ); + } + const QString fsLabel = m_ui->fileSystemLabelEdit->text(); + + const auto resultFlags = newFlags(); + const auto currentFlags = PartitionInfo::flags( m_partition ); + + cDebug() << m_partition->partitionPath() << "format?" << m_ui->formatRadioButton->isChecked() << "label=" << fsLabel + << "mount=" << mountPoint; + + if ( partResizedMoved ) + { + cDebug() << "old boundaries:" << m_partition->firstSector() << m_partition->lastSector() + << m_partition->length(); + cDebug() << Logger::SubEntry << "new boundaries:" << newFirstSector << newLastSector; + + if ( m_ui->formatRadioButton->isChecked() ) + { + Partition* newPartition = KPMHelpers::createNewPartition( m_partition->parent(), + *m_device, + m_partition->roles(), + fsType, + fsLabel, + newFirstSector, + newLastSector, + resultFlags ); + PartitionInfo::setMountPoint( newPartition, PartitionInfo::mountPoint( m_partition ) ); + PartitionInfo::setFormat( newPartition, true ); + + core->deletePartition( m_device, m_partition ); + core->createPartition( m_device, newPartition ); + core->setPartitionFlags( m_device, newPartition, resultFlags ); + } + else + { + core->resizePartition( m_device, m_partition, newFirstSector, newLastSector ); + if ( currentFlags != resultFlags ) + { + core->setPartitionFlags( m_device, m_partition, resultFlags ); + } + updateLabel( core, m_device, m_partition, fsLabel ); + PartitionInfo::setFormat( m_partition, false ); + } + } + else + { + // No size changes + if ( m_ui->formatRadioButton->isChecked() ) + { + // if the FS type is unchanged, we just format + if ( m_partition->fileSystem().type() == fsType ) + { + core->formatPartition( m_device, m_partition ); + if ( currentFlags != resultFlags ) + { + core->setPartitionFlags( m_device, m_partition, resultFlags ); + } + core->setFilesystemLabel( m_device, m_partition, fsLabel ); + PartitionInfo::setFormat( m_partition, true ); + } + else // otherwise, we delete and recreate the partition with new fs type + { + Partition* newPartition = KPMHelpers::createNewPartition( m_partition->parent(), + *m_device, + m_partition->roles(), + fsType, + fsLabel, + m_partition->firstSector(), + m_partition->lastSector(), + resultFlags ); + PartitionInfo::setMountPoint( newPartition, PartitionInfo::mountPoint( m_partition ) ); + PartitionInfo::setFormat( newPartition, true ); + + core->deletePartition( m_device, m_partition ); + core->createPartition( m_device, newPartition ); + core->setPartitionFlags( m_device, newPartition, resultFlags ); + } + } + else + { + if ( currentFlags != resultFlags ) + { + core->setPartitionFlags( m_device, m_partition, resultFlags ); + } + updateLabel( core, m_device, m_partition, fsLabel ); + PartitionInfo::setFormat( m_partition, false ); + + core->refreshPartition( m_device, m_partition ); + } + + // Update the existing luks partition + const QString passphrase = m_ui->encryptWidget->passphrase(); + if ( !passphrase.isEmpty() ) + { + if ( KPMHelpers::savePassphrase( m_partition, passphrase ) != KPMHelpers::SavePassphraseValue::NoError ) + { + QString message = tr( "Passphrase for existing partition" ); + QString description = tr( "Partition %1 could not be decrypted " + "with the given passphrase." + "

    " + "Edit the partition again and give the correct passphrase " + "or delete and create a new encrypted partition." ) + .arg( m_partition->partitionPath() ); + + QMessageBox mb( QMessageBox::Information, message, description, QMessageBox::Ok, this->parentWidget() ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); + } + } + } +} + + +void +EditExistingPartitionDialog::replacePartResizerWidget() +{ + /* + * There is no way to reliably update the partition used by + * PartResizerWidget, which is necessary when we switch between "format" and + * "keep". This is a hack which replaces the existing PartResizerWidget + * with a new one. + */ + PartResizerWidget* widget = new PartResizerWidget( this ); + + layout()->replaceWidget( m_ui->partResizerWidget, widget ); + delete m_ui->partResizerWidget; + m_ui->partResizerWidget = widget; + + m_partitionSizeController->setPartResizerWidget( widget, m_ui->formatRadioButton->isChecked() ); +} + +void +EditExistingPartitionDialog::updateMountPointPicker() +{ + bool doFormat = m_ui->formatRadioButton->isChecked(); + FileSystem::Type fsType = FileSystem::Unknown; + if ( doFormat ) + { + fsType = FileSystem::typeForName( m_ui->fileSystemComboBox->currentText() ); + } + else + { + fsType = m_partition->fileSystem().type(); + } + bool canMount = true; + if ( fsType == FileSystem::Extended || fsType == FileSystem::LinuxSwap || fsType == FileSystem::Unformatted + || fsType == FileSystem::Unknown || fsType == FileSystem::Lvm2_PV ) + { + canMount = false; + } + + m_ui->mountPointLabel->setEnabled( canMount ); + m_ui->mountPointComboBox->setEnabled( canMount ); + if ( !canMount ) + { + setSelectedMountPoint( m_ui->mountPointComboBox, QString() ); + } + + toggleEncryptWidget(); +} + +void +EditExistingPartitionDialog::checkMountPointSelection() +{ + if ( validateMountPoint( m_core, + selectedMountPoint( m_ui->mountPointComboBox ), + m_usedMountPoints, + m_ui->fileSystemComboBox->currentText(), + m_ui->mountPointExplanation, + m_ui->buttonBox->button( QDialogButtonBox::Ok ) ) ) + { + toggleEncryptWidget(); + } +} + +void +EditExistingPartitionDialog::toggleEncryptWidget() +{ + // Show/hide encryptWidget: + // check if partition is a previously luks formatted partition + // and not currently formatted + // and its mount point not a standard mount point except when it's /home + QString mp = selectedMountPoint( m_ui->mountPointComboBox ); + if ( !mp.isEmpty() && m_partition->fileSystem().type() == FileSystem::Luks && !m_ui->formatRadioButton->isChecked() + && ( !standardMountPoints().contains( mp ) || mp == "/home" ) ) + { + m_ui->encryptWidget->show(); + m_ui->encryptWidget->reset( false ); + } + // TODO: When formatting a partition user must be able to encrypt that partition + // Probably need to delete this partition and create a new one + // else if ( m_ui->formatRadioButton->isChecked() + // && !mp.isEmpty()) + // { + // m_ui->encryptWidget->show(); + // m_ui->encryptWidget->reset(); + // } + else + { + m_ui->encryptWidget->reset(); + m_ui->encryptWidget->hide(); + } +} diff --git a/calamares/src/modules/partition/gui/EditExistingPartitionDialog.h b/calamares/src/modules/partition/gui/EditExistingPartitionDialog.h new file mode 100644 index 0000000..8674b8b --- /dev/null +++ b/calamares/src/modules/partition/gui/EditExistingPartitionDialog.h @@ -0,0 +1,66 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef EDITEXISTINGPARTITIONDIALOG_H +#define EDITEXISTINGPARTITIONDIALOG_H + +#include + +#include +#include + +class PartitionCoreModule; +class Device; +class Partition; +class PartitionSizeController; +class Ui_EditExistingPartitionDialog; + +/** + * The dialog which is shown to edit a partition which already existed when the installer started. + * + * It lets you decide how to reuse the partition: whether to keep its content + * or reformat it, whether to resize or move it. + */ +class EditExistingPartitionDialog : public QDialog +{ + Q_OBJECT +public: + struct FreeSpace + { + Partition* p; + }; + + EditExistingPartitionDialog( PartitionCoreModule* core, + Device* device, + Partition* partition, + const QStringList& usedMountPoints, + QWidget* parentWidget = nullptr ); + ~EditExistingPartitionDialog() override; + + void applyChanges( PartitionCoreModule* module ); + +private slots: + void checkMountPointSelection(); + +private: + QScopedPointer< Ui_EditExistingPartitionDialog > m_ui; + PartitionCoreModule* m_core; + Device* m_device; + Partition* m_partition; + PartitionSizeController* m_partitionSizeController; + QStringList m_usedMountPoints; + + PartitionTable::Flags newFlags() const; + void replacePartResizerWidget(); + void updateMountPointPicker(); + void toggleEncryptWidget(); +}; + +#endif /* EDITEXISTINGPARTITIONDIALOG_H */ diff --git a/calamares/src/modules/partition/gui/EditExistingPartitionDialog.ui b/calamares/src/modules/partition/gui/EditExistingPartitionDialog.ui new file mode 100644 index 0000000..ff9cc33 --- /dev/null +++ b/calamares/src/modules/partition/gui/EditExistingPartitionDialog.ui @@ -0,0 +1,289 @@ + + + +SPDX-FileCopyrightText: 2014 Aurélien Gâteau <agateau@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + EditExistingPartitionDialog + + + + 0 + 0 + 570 + 689 + + + + + 0 + 0 + + + + Edit Existing Partition + + + + QLayout::SetMinimumSize + + + + + + 0 + 0 + + + + + 0 + 59 + + + + + + + + QFormLayout::ExpandingFieldsGrow + + + + + Con&tent: + + + keepRadioButton + + + + + + + &Keep + + + true + + + + + + + Format + + + + + + + + 0 + 0 + + + + + 300 + 0 + + + + Warning: Formatting the partition will erase all existing data. + + + true + + + + + + + &Mount Point: + + + mountPointComboBox + + + + + + + + 0 + 0 + + + + true + + + -1 + + + + + + + Si&ze: + + + sizeSpinBox + + + + + + + MiB + + + + + + + Fi&le System: + + + fileSystemComboBox + + + + + + + + + + Flags: + + + + + + + true + + + QAbstractItemView::NoSelection + + + true + + + + + + + Label for the filesystem + + + 16 + + + + + + + FS Label: + + + + + + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 13 + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + PartResizerWidget + QWidget +
    kpmcore/gui/partresizerwidget.h
    + 1 +
    + + EncryptWidget + QWidget +
    gui/EncryptWidget.h
    + 1 +
    +
    + + sizeSpinBox + keepRadioButton + formatRadioButton + mountPointComboBox + buttonBox + + + + + buttonBox + accepted() + EditExistingPartitionDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + EditExistingPartitionDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
    diff --git a/calamares/src/modules/partition/gui/EncryptWidget.cpp b/calamares/src/modules/partition/gui/EncryptWidget.cpp new file mode 100644 index 0000000..58c1f51 --- /dev/null +++ b/calamares/src/modules/partition/gui/EncryptWidget.cpp @@ -0,0 +1,228 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2023 Evan James + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "EncryptWidget.h" + +#include "ui_EncryptWidget.h" + +#include "Branding.h" +#include "utils/Gui.h" +#include "utils/Retranslator.h" + +constexpr int ZFS_MIN_LENGTH = 8; + +/** @brief Does this system support whole-disk encryption? + * + * Returns @c true if the system is likely to support encryption + * with sufficient performance to be usable. A machine that can't + * doe hardware-assisted AES is **probably** too slow, so we could + * warn the user that ticking the "encrypt system" box is a bad + * idea. + * + * Since we don't have an oracle that can answer that question, + * just pretend every system can do it. + */ +static inline bool +systemSupportsEncryptionAcceptably() +{ + return true; +} + +EncryptWidget::EncryptWidget( QWidget* parent ) + : QWidget( parent ) + , m_ui( new Ui::EncryptWidget ) + , m_state( Encryption::Disabled ) +{ + m_ui->setupUi( this ); + + m_ui->m_iconLabel->setFixedWidth( m_ui->m_iconLabel->height() ); + m_ui->m_passphraseLineEdit->hide(); + m_ui->m_confirmLineEdit->hide(); + m_ui->m_iconLabel->hide(); + // TODO: this deserves better rendering, an icon or something, but that will + // depend on having a non-bogus implementation of systemSupportsEncryptionAcceptably + if ( systemSupportsEncryptionAcceptably() ) + { + m_ui->m_encryptionUnsupportedLabel->hide(); + } + else + { + // This is really ugly, but the character is unicode "unlocked" + m_ui->m_encryptionUnsupportedLabel->setText( QStringLiteral( "🔓" ) ); + m_ui->m_encryptionUnsupportedLabel->show(); + } + + connect( + m_ui->m_encryptCheckBox, Calamares::checkBoxStateChangedSignal, this, &EncryptWidget::onCheckBoxStateChanged ); + connect( m_ui->m_passphraseLineEdit, &QLineEdit::textEdited, this, &EncryptWidget::onPassphraseEdited ); + connect( m_ui->m_confirmLineEdit, &QLineEdit::textEdited, this, &EncryptWidget::onPassphraseEdited ); + + setFixedHeight( m_ui->m_passphraseLineEdit->height() ); // Avoid jumping up and down + updateState(); + + CALAMARES_RETRANSLATE_SLOT( &EncryptWidget::retranslate ); +} + +bool +EncryptWidget::isEncryptionCheckboxChecked() +{ + return m_ui->m_encryptCheckBox->isChecked(); +} + +void +EncryptWidget::setEncryptionCheckbox( bool preCheckEncrypt ) +{ + m_ui->m_encryptCheckBox->setChecked( preCheckEncrypt ); +} + +void +EncryptWidget::reset( bool checkVisible ) +{ + m_ui->m_passphraseLineEdit->clear(); + m_ui->m_confirmLineEdit->clear(); + + m_ui->m_encryptCheckBox->setChecked( false ); + + m_ui->m_encryptCheckBox->setVisible( checkVisible ); + m_ui->m_passphraseLineEdit->setVisible( !checkVisible ); + m_ui->m_confirmLineEdit->setVisible( !checkVisible ); +} + +EncryptWidget::Encryption +EncryptWidget::state() const +{ + Encryption newState = Encryption::Unconfirmed; + + if ( m_ui->m_encryptCheckBox->isChecked() || !m_ui->m_encryptCheckBox->isVisible() ) + { + if ( !m_ui->m_passphraseLineEdit->text().isEmpty() + && m_ui->m_passphraseLineEdit->text() == m_ui->m_confirmLineEdit->text() ) + { + newState = Encryption::Confirmed; + } + else + { + newState = Encryption::Unconfirmed; + } + } + else + { + newState = Encryption::Disabled; + } + + return newState; +} + +void +EncryptWidget::setText( const QString& text ) +{ + m_ui->m_encryptCheckBox->setText( text ); +} + +QString +EncryptWidget::passphrase() const +{ + if ( m_state == Encryption::Confirmed ) + { + return m_ui->m_passphraseLineEdit->text(); + } + return QString(); +} + +void +EncryptWidget::retranslate() +{ + m_ui->retranslateUi( this ); + onPassphraseEdited(); // For the tooltip +} + +///@brief Give @p label the @p pixmap from the standard-pixmaps +static void +applyPixmap( QLabel* label, Calamares::ImageType pixmap ) +{ + label->setFixedWidth( label->height() ); + label->setPixmap( Calamares::defaultPixmap( pixmap, Calamares::Original, label->size() ) ); +} + +void +EncryptWidget::updateState( const bool notify ) +{ + if ( m_ui->m_passphraseLineEdit->isVisible() ) + { + QString p1 = m_ui->m_passphraseLineEdit->text(); + QString p2 = m_ui->m_confirmLineEdit->text(); + + if ( p1.isEmpty() && p2.isEmpty() ) + { + applyPixmap( m_ui->m_iconLabel, Calamares::StatusWarning ); + m_ui->m_iconLabel->setToolTip( tr( "Please enter the same passphrase in both boxes.", "@tooltip" ) ); + } + else if ( m_filesystem == FileSystem::Zfs && p1.length() < ZFS_MIN_LENGTH ) + { + applyPixmap( m_ui->m_iconLabel, Calamares::StatusError ); + m_ui->m_iconLabel->setToolTip( + tr( "Password must be a minimum of %1 characters.", "@tooltip" ).arg( ZFS_MIN_LENGTH ) ); + } + else if ( p1 == p2 ) + { + applyPixmap( m_ui->m_iconLabel, Calamares::StatusOk ); + m_ui->m_iconLabel->setToolTip( QString() ); + } + else + { + applyPixmap( m_ui->m_iconLabel, Calamares::StatusError ); + m_ui->m_iconLabel->setToolTip( tr( "Please enter the same passphrase in both boxes.", "@tooltip" ) ); + } + } + + Encryption newState = state(); + + m_state = newState; + if ( notify ) + { + Q_EMIT stateChanged( m_state ); + } +} + +void +EncryptWidget::onPassphraseEdited() +{ + if ( !m_ui->m_iconLabel->isVisible() ) + { + m_ui->m_iconLabel->show(); + } + + updateState(); +} + +void +EncryptWidget::onCheckBoxStateChanged( Calamares::checkBoxStateType checked ) +{ + const bool visible = ( checked != Calamares::checkBoxUncheckedValue ); + m_ui->m_passphraseLineEdit->setVisible( visible ); + m_ui->m_confirmLineEdit->setVisible( visible ); + m_ui->m_iconLabel->setVisible( visible ); + m_ui->m_passphraseLineEdit->clear(); + m_ui->m_confirmLineEdit->clear(); + m_ui->m_iconLabel->clear(); + + updateState(); +} + +void +EncryptWidget::setFilesystem( const FileSystem::Type fs ) +{ + m_filesystem = fs; + if ( m_state != Encryption::Disabled ) + { + updateState( false ); + } +} diff --git a/calamares/src/modules/partition/gui/EncryptWidget.h b/calamares/src/modules/partition/gui/EncryptWidget.h new file mode 100644 index 0000000..6f3db75 --- /dev/null +++ b/calamares/src/modules/partition/gui/EncryptWidget.h @@ -0,0 +1,72 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2023 Evan James + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + + +#ifndef ENCRYPTWIDGET_H +#define ENCRYPTWIDGET_H + +#include "compat/CheckBox.h" + +#include + +#include + +namespace Ui +{ +class EncryptWidget; +} // namespace Ui + +class EncryptWidget : public QWidget +{ + Q_OBJECT + +public: + enum class Encryption : unsigned short + { + Disabled = 0, + Unconfirmed, + Confirmed + }; + + explicit EncryptWidget( QWidget* parent = nullptr ); + + void setEncryptionCheckbox( bool preCheckEncrypt = false ); + void reset( bool checkVisible = true ); + + bool isEncryptionCheckboxChecked(); + Encryption state() const; + void setText( const QString& text ); + + /** + * @brief setFilesystem sets the filesystem name used for password validation + * @param fs A QString containing the name of the filesystem + */ + void setFilesystem( const FileSystem::Type fs ); + + QString passphrase() const; + + void retranslate(); + +signals: + void stateChanged( Encryption ); + +private: + void updateState( const bool notify = true ); + void onPassphraseEdited(); + void onCheckBoxStateChanged( Calamares::checkBoxStateType checked ); + + Ui::EncryptWidget* m_ui; + Encryption m_state; + + FileSystem::Type m_filesystem; +}; + +#endif // ENCRYPTWIDGET_H diff --git a/calamares/src/modules/partition/gui/EncryptWidget.ui b/calamares/src/modules/partition/gui/EncryptWidget.ui new file mode 100644 index 0000000..24d63b5 --- /dev/null +++ b/calamares/src/modules/partition/gui/EncryptWidget.ui @@ -0,0 +1,100 @@ + + + +SPDX-FileCopyrightText: 2016 Teo Mrnjavac <teo@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + EncryptWidget + + + + 0 + 0 + 822 + 59 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + En&crypt system + + + + + + + Your system does not seem to support encryption well enough to encrypt the entire system. You may enable encryption, but performance may suffer. + + + 🔓 + + + Qt::AlignCenter + + + + + + + QLineEdit::Password + + + Passphrase + + + + + + + QLineEdit::Password + + + Confirm passphrase + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Qt::AlignCenter + + + + + + + + diff --git a/calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.cpp b/calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.cpp new file mode 100644 index 0000000..8eeafcb --- /dev/null +++ b/calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.cpp @@ -0,0 +1,29 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ListPhysicalVolumeWidgetItem.h" + +#include "core/SizeUtils.h" + +ListPhysicalVolumeWidgetItem::ListPhysicalVolumeWidgetItem( const Partition* partition, bool checked ) + : QListWidgetItem( QString( "%1 | %2" ).arg( partition->deviceNode(), formatByteSize( partition->capacity() ) ) ) + , m_partition( partition ) +{ + setToolTip( partition->deviceNode() ); + setSizeHint( QSize( 0, 32 ) ); + setCheckState( checked ? Qt::Checked : Qt::Unchecked ); +} + +const Partition* +ListPhysicalVolumeWidgetItem::partition() const +{ + return m_partition; +} + +ListPhysicalVolumeWidgetItem::~ListPhysicalVolumeWidgetItem() {} diff --git a/calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.h b/calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.h new file mode 100644 index 0000000..5d7fdcb --- /dev/null +++ b/calamares/src/modules/partition/gui/ListPhysicalVolumeWidgetItem.h @@ -0,0 +1,29 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef LISTPHYSICALVOLUMEWIDGETITEM_H +#define LISTPHYSICALVOLUMEWIDGETITEM_H + +#include + +#include + +class ListPhysicalVolumeWidgetItem : public QListWidgetItem +{ +public: + ListPhysicalVolumeWidgetItem( const Partition* partition, bool checked ); + ~ListPhysicalVolumeWidgetItem() override; + + const Partition* partition() const; + +private: + const Partition* m_partition; +}; + +#endif // LISTPHYSICALVOLUMEWIDGETITEM_H diff --git a/calamares/src/modules/partition/gui/PartitionBarsView.cpp b/calamares/src/modules/partition/gui/PartitionBarsView.cpp new file mode 100644 index 0000000..ef748d2 --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionBarsView.cpp @@ -0,0 +1,535 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "gui/PartitionBarsView.h" + +#include "core/ColorUtils.h" +#include "core/PartitionModel.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" + +#include + +#include +#include +#include +#include + +static const int VIEW_HEIGHT = qMax( Calamares::defaultFontHeight() + 8, // wins out with big fonts + int( Calamares::defaultFontHeight() * 0.6 ) + 22 ); // wins out with small fonts +static constexpr int CORNER_RADIUS = 3; +static const int EXTENDED_PARTITION_MARGIN = qMax( 4, VIEW_HEIGHT / 6 ); + +// The SELECTION_MARGIN is applied within a hardcoded 2px padding anyway, so +// we start from EXTENDED_PARTITION_MARGIN - 2 in all cases. +// Then we try to ensure the selection rectangle fits exactly between the extended +// rectangle and the outer frame (the "/ 2" part), unless that's not possible, and in +// that case we at least make sure we have a 1px gap between the selection rectangle +// and the extended partition box (the "- 2" part). +// At worst, on low DPI systems, this will mean in order: +// 1px outer rect, 1 px gap, 1px selection rect, 1px gap, 1px extended partition rect. +static const int SELECTION_MARGIN + = qMin( ( EXTENDED_PARTITION_MARGIN - 2 ) / 2, ( EXTENDED_PARTITION_MARGIN - 2 ) - 2 ); + +PartitionBarsView::PartitionBarsView( QWidget* parent ) + : QAbstractItemView( parent ) + , m_nestedPartitionsMode( NoNestedPartitions ) + , canBeSelected( []( const QModelIndex& ) { return true; } ) + , m_hoveredIndex( QModelIndex() ) +{ + this->setObjectName( "partitionBarView" ); + setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Fixed ); + setFrameStyle( QFrame::NoFrame ); + setSelectionBehavior( QAbstractItemView::SelectRows ); + setSelectionMode( QAbstractItemView::SingleSelection ); + + // Debug + connect( this, + &PartitionBarsView::clicked, + this, + [ = ]( const QModelIndex& index ) { cDebug() << "Clicked row" << index.row(); } ); + setMouseTracking( true ); +} + +PartitionBarsView::~PartitionBarsView() {} + +void +PartitionBarsView::setNestedPartitionsMode( PartitionBarsView::NestedPartitionsMode mode ) +{ + m_nestedPartitionsMode = mode; + viewport()->repaint(); +} + +QSize +PartitionBarsView::minimumSizeHint() const +{ + return sizeHint(); +} + +QSize +PartitionBarsView::sizeHint() const +{ + return QSize( -1, VIEW_HEIGHT ); +} + +void +PartitionBarsView::paintEvent( QPaintEvent* event ) +{ + QPainter painter( viewport() ); + painter.fillRect( rect(), palette().window() ); + painter.setRenderHint( QPainter::Antialiasing ); + + QRect partitionsRect = rect(); + partitionsRect.setHeight( VIEW_HEIGHT ); + + painter.save(); + drawPartitions( &painter, partitionsRect, QModelIndex() ); + painter.restore(); +} + +void +PartitionBarsView::drawSection( QPainter* painter, const QRect& rect_, int x, int width, const QModelIndex& index ) +{ + QColor color + = index.isValid() ? index.data( Qt::DecorationRole ).value< QColor >() : ColorUtils::unknownDisklabelColor(); + bool isFreeSpace = index.isValid() ? index.data( PartitionModel::IsFreeSpaceRole ).toBool() : true; + + QRect rect = rect_; + const int y = rect.y(); + const int height = rect.height(); + const int radius = qMax( 1, CORNER_RADIUS - ( VIEW_HEIGHT - height ) / 2 ); + painter->setClipRect( x, y, width, height ); + painter->translate( 0.5, 0.5 ); + + rect.adjust( 0, 0, -1, -1 ); + + if ( selectionMode() != QAbstractItemView::NoSelection && // no hover without selection + m_hoveredIndex.isValid() && index == m_hoveredIndex ) + { + if ( canBeSelected( index ) ) + { + painter->setBrush( color.lighter( 115 ) ); + } + else + { + painter->setBrush( color ); + } + } + else + { + painter->setBrush( color ); + } + + QColor borderColor = color.darker(); + + painter->setPen( borderColor ); + + painter->drawRoundedRect( rect, radius, radius ); + + // Draw shade + if ( !isFreeSpace ) + { + rect.adjust( 2, 2, -2, -2 ); + } + + QLinearGradient gradient( 0, 0, 0, height / 2 ); + + qreal c = isFreeSpace ? 0 : 1; + gradient.setColorAt( 0, QColor::fromRgbF( c, c, c, 0.3 ) ); + gradient.setColorAt( 1, QColor::fromRgbF( c, c, c, 0 ) ); + + painter->setPen( Qt::NoPen ); + + painter->setBrush( gradient ); + painter->drawRoundedRect( rect, radius, radius ); + + if ( selectionMode() != QAbstractItemView::NoSelection && index.isValid() && selectionModel() + && !selectionModel()->selectedIndexes().isEmpty() && selectionModel()->selectedIndexes().first() == index ) + { + painter->setPen( QPen( borderColor, 1 ) ); + QColor highlightColor = QPalette().highlight().color(); + highlightColor = highlightColor.lighter( 500 ); + highlightColor.setAlpha( 120 ); + painter->setBrush( highlightColor ); + + QRect selectionRect = rect; + selectionRect.setX( x + 1 ); + selectionRect.setWidth( width - 3 ); //account for the previous rect.adjust + + if ( rect.x() > selectionRect.x() ) //hack for first item + { + selectionRect.adjust( rect.x() - selectionRect.x(), 0, 0, 0 ); + } + + if ( rect.right() < selectionRect.right() ) //hack for last item + { + selectionRect.adjust( 0, 0, -( selectionRect.right() - rect.right() ), 0 ); + } + + selectionRect.adjust( SELECTION_MARGIN, SELECTION_MARGIN, -SELECTION_MARGIN, -SELECTION_MARGIN ); + + painter->drawRoundedRect( selectionRect, radius - 1, radius - 1 ); + } + + painter->translate( -0.5, -0.5 ); +} + +void +PartitionBarsView::drawPartitions( QPainter* painter, const QRect& rect, const QModelIndex& parent ) +{ + PartitionModel* modl = qobject_cast< PartitionModel* >( model() ); + if ( !modl ) + { + return; + } + const int totalWidth = rect.width(); + + auto pair = computeItemsVector( parent ); + QVector< PartitionBarsView::Item >& items = pair.first; + qreal& total = pair.second; + int x = rect.x(); + for ( int row = 0; row < items.count(); ++row ) + { + const auto& item = items[ row ]; + int width; + if ( row < items.count() - 1 ) + { + width = totalWidth * ( item.size / total ); + } + else + // Make sure we fill the last pixel column + { + width = rect.right() - x + 1; + } + + drawSection( painter, rect, x, width, item.index ); + + if ( m_nestedPartitionsMode == DrawNestedPartitions && modl->hasChildren( item.index ) ) + { + QRect subRect( x + EXTENDED_PARTITION_MARGIN, + rect.y() + EXTENDED_PARTITION_MARGIN, + width - 2 * EXTENDED_PARTITION_MARGIN, + rect.height() - 2 * EXTENDED_PARTITION_MARGIN ); + drawPartitions( painter, subRect, item.index ); + } + x += width; + } + + if ( !items.count() && !modl->device()->partitionTable() ) // No disklabel or unknown + { + int width = rect.right() - rect.x() + 1; + drawSection( painter, rect, rect.x(), width, QModelIndex() ); + } +} + +QModelIndex +PartitionBarsView::indexAt( const QPoint& point ) const +{ + return indexAt( point, rect(), QModelIndex() ); +} + +QModelIndex +PartitionBarsView::indexAt( const QPoint& point, const QRect& rect, const QModelIndex& parent ) const +{ + PartitionModel* modl = qobject_cast< PartitionModel* >( model() ); + if ( !modl ) + { + return QModelIndex(); + } + const int totalWidth = rect.width(); + + auto pair = computeItemsVector( parent ); + QVector< PartitionBarsView::Item >& items = pair.first; + qreal& total = pair.second; + int x = rect.x(); + for ( int row = 0; row < items.count(); ++row ) + { + const auto& item = items[ row ]; + int width; + if ( row < items.count() - 1 ) + { + width = totalWidth * ( item.size / total ); + } + else + // Make sure we fill the last pixel column + { + width = rect.right() - x + 1; + } + + QRect thisItemRect( x, rect.y(), width, rect.height() ); + if ( thisItemRect.contains( point ) ) + { + if ( m_nestedPartitionsMode == DrawNestedPartitions && modl->hasChildren( item.index ) ) + { + QRect subRect( x + EXTENDED_PARTITION_MARGIN, + rect.y() + EXTENDED_PARTITION_MARGIN, + width - 2 * EXTENDED_PARTITION_MARGIN, + rect.height() - 2 * EXTENDED_PARTITION_MARGIN ); + + if ( subRect.contains( point ) ) + { + return indexAt( point, subRect, item.index ); + } + return item.index; + } + else // contains but no children, we win + { + return item.index; + } + } + x += width; + } + + return QModelIndex(); +} + +QRect +PartitionBarsView::visualRect( const QModelIndex& index ) const +{ + return visualRect( index, rect(), QModelIndex() ); +} + +QRect +PartitionBarsView::visualRect( const QModelIndex& index, const QRect& rect, const QModelIndex& parent ) const +{ + PartitionModel* modl = qobject_cast< PartitionModel* >( model() ); + if ( !modl ) + { + return QRect(); + } + const int totalWidth = rect.width(); + + auto pair = computeItemsVector( parent ); + QVector< PartitionBarsView::Item >& items = pair.first; + qreal& total = pair.second; + int x = rect.x(); + for ( int row = 0; row < items.count(); ++row ) + { + const auto& item = items[ row ]; + int width; + if ( row < items.count() - 1 ) + { + width = totalWidth * ( item.size / total ); + } + else + // Make sure we fill the last pixel column + { + width = rect.right() - x + 1; + } + + QRect thisItemRect( x, rect.y(), width, rect.height() ); + if ( item.index == index ) + { + return thisItemRect; + } + + if ( m_nestedPartitionsMode == DrawNestedPartitions && modl->hasChildren( item.index ) + && index.parent() == item.index ) + { + QRect subRect( x + EXTENDED_PARTITION_MARGIN, + rect.y() + EXTENDED_PARTITION_MARGIN, + width - 2 * EXTENDED_PARTITION_MARGIN, + rect.height() - 2 * EXTENDED_PARTITION_MARGIN ); + + QRect candidateVisualRect = visualRect( index, subRect, item.index ); + if ( !candidateVisualRect.isNull() ) + { + return candidateVisualRect; + } + } + + x += width; + } + + return QRect(); +} + +QRegion +PartitionBarsView::visualRegionForSelection( const QItemSelection& selection ) const +{ + return QRegion(); +} + +int +PartitionBarsView::horizontalOffset() const +{ + return 0; +} + +int +PartitionBarsView::verticalOffset() const +{ + return 0; +} + +void +PartitionBarsView::scrollTo( const QModelIndex& index, ScrollHint hint ) +{ + Q_UNUSED( index ) + Q_UNUSED( hint ) +} + +void +PartitionBarsView::setSelectionModel( QItemSelectionModel* selectionModel ) +{ + QAbstractItemView::setSelectionModel( selectionModel ); + connect( selectionModel, &QItemSelectionModel::selectionChanged, this, [ = ] { viewport()->repaint(); } ); +} + +void +PartitionBarsView::setSelectionFilter( std::function< bool( const QModelIndex& ) > canBeSelected ) +{ + this->canBeSelected = canBeSelected; +} + +QModelIndex +PartitionBarsView::moveCursor( CursorAction, Qt::KeyboardModifiers ) +{ + return QModelIndex(); +} + +bool +PartitionBarsView::isIndexHidden( const QModelIndex& ) const +{ + return false; +} + +void +PartitionBarsView::setSelection( const QRect& rect, QItemSelectionModel::SelectionFlags flags ) +{ + //HACK: this is an utterly awful workaround, which is unfortunately necessary. + // QAbstractItemView::mousePressedEvent calls setSelection, but before that, + // for some mental reason, it works under the assumption that every item is a + // rectangle. This rectangle is provided by visualRect, and the idea mostly + // works, except when the item is an extended partition item, which is of course + // a rectangle with a rectangular hole in the middle. + // QAbstractItemView::mousePressEvent builds a QRect with x1, y1 in the center + // of said visualRect, and x2, y2 in the real QMouseEvent position. + // This may very well yield a QRect with negative size, which is meaningless. + // Therefore the QRect we get here is totally bogus, and its topLeft is outside + // the actual area of the item we need. + // What we need are the real coordinates of the QMouseEvent, and the only way to + // get them is by fetching the private x2, y2 from the rect. + // TL;DR: this sucks, look away. -- Teo 12/2015 + int x1, y1, x2, y2; + rect.getCoords( &x1, &y1, &x2, &y2 ); + + QModelIndex eventIndex = indexAt( QPoint( x2, y2 ) ); + if ( canBeSelected( eventIndex ) ) + { + selectionModel()->select( eventIndex, flags ); + } + + viewport()->repaint(); +} + +void +PartitionBarsView::mouseMoveEvent( QMouseEvent* event ) +{ + QModelIndex candidateIndex = indexAt( event->pos() ); + QPersistentModelIndex oldHoveredIndex = m_hoveredIndex; + if ( candidateIndex.isValid() ) + { + m_hoveredIndex = candidateIndex; + } + else + { + m_hoveredIndex = QModelIndex(); + QGuiApplication::restoreOverrideCursor(); + } + + if ( oldHoveredIndex != m_hoveredIndex ) + { + if ( m_hoveredIndex.isValid() && !canBeSelected( m_hoveredIndex ) ) + { + QGuiApplication::setOverrideCursor( Qt::ForbiddenCursor ); + } + else + { + QGuiApplication::restoreOverrideCursor(); + } + + viewport()->repaint(); + } +} + +void +PartitionBarsView::leaveEvent( QEvent* ) +{ + QGuiApplication::restoreOverrideCursor(); + if ( m_hoveredIndex.isValid() ) + { + m_hoveredIndex = QModelIndex(); + viewport()->repaint(); + } +} + +void +PartitionBarsView::mousePressEvent( QMouseEvent* event ) +{ + QModelIndex candidateIndex = indexAt( event->pos() ); + if ( canBeSelected( candidateIndex ) ) + { + QAbstractItemView::mousePressEvent( event ); + } + else + { + event->accept(); + } +} + +void +PartitionBarsView::updateGeometries() +{ + updateGeometry(); //get a new rect() for redrawing all the labels +} + +QPair< QVector< PartitionBarsView::Item >, qreal > +PartitionBarsView::computeItemsVector( const QModelIndex& parent ) const +{ + int count = model()->rowCount( parent ); + QVector< PartitionBarsView::Item > items; + + qreal total = 0; + for ( int row = 0; row < count; ++row ) + { + QModelIndex index = model()->index( row, 0, parent ); + if ( m_nestedPartitionsMode == NoNestedPartitions && model()->hasChildren( index ) ) + { + QPair< QVector< PartitionBarsView::Item >, qreal > childVect = computeItemsVector( index ); + items += childVect.first; + total += childVect.second; + } + else + { + qreal size = index.data( PartitionModel::SizeRole ).toLongLong(); + total += size; + items.append( { size, index } ); + } + } + + count = items.count(); + + // The sizes we have are perfect, but now we have to hardcode a minimum size for small + // partitions and compensate for it in the total. + qreal adjustedTotal = total; + for ( int row = 0; row < count; ++row ) + { + if ( items[ row ].size < 0.01 * total ) // If this item is smaller than 1% of everything, + { + // force its width to 1%. + adjustedTotal -= items[ row ].size; + items[ row ].size = 0.01 * total; + adjustedTotal += items[ row ].size; + } + } + + return qMakePair( items, adjustedTotal ); +} diff --git a/calamares/src/modules/partition/gui/PartitionBarsView.h b/calamares/src/modules/partition/gui/PartitionBarsView.h new file mode 100644 index 0000000..39c3baf --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionBarsView.h @@ -0,0 +1,91 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#ifndef PARTITIONPREVIEW_H +#define PARTITIONPREVIEW_H + +#include "PartitionViewSelectionFilter.h" + +#include + + +/** + * A Qt model view which displays the partitions inside a device as a colored bar. + * + * It has been created to be used with a PartitionModel instance, but does not + * call any PartitionModel-specific methods: it should be usable with other + * models as long as they provide the same roles PartitionModel provides. + */ +class PartitionBarsView : public QAbstractItemView +{ + Q_OBJECT +public: + enum NestedPartitionsMode + { + NoNestedPartitions = 0, + DrawNestedPartitions + }; + + explicit PartitionBarsView( QWidget* parent = nullptr ); + ~PartitionBarsView() override; + + void setNestedPartitionsMode( NestedPartitionsMode mode ); + + QSize minimumSizeHint() const override; + + QSize sizeHint() const override; + + void paintEvent( QPaintEvent* event ) override; + + // QAbstractItemView API + QModelIndex indexAt( const QPoint& point ) const override; + QRect visualRect( const QModelIndex& index ) const override; + void scrollTo( const QModelIndex& index, ScrollHint hint = EnsureVisible ) override; + + void setSelectionModel( QItemSelectionModel* selectionModel ) override; + + void setSelectionFilter( SelectionFilter canBeSelected ); + +protected: + // QAbstractItemView API + QRegion visualRegionForSelection( const QItemSelection& selection ) const override; + int horizontalOffset() const override; + int verticalOffset() const override; + bool isIndexHidden( const QModelIndex& index ) const override; + QModelIndex moveCursor( CursorAction cursorAction, Qt::KeyboardModifiers modifiers ) override; + void setSelection( const QRect& rect, QItemSelectionModel::SelectionFlags flags ) override; + + void mouseMoveEvent( QMouseEvent* event ) override; + void leaveEvent( QEvent* event ) override; + void mousePressEvent( QMouseEvent* event ) override; + +protected slots: + void updateGeometries() override; + +private: + void drawPartitions( QPainter* painter, const QRect& rect, const QModelIndex& parent ); + void drawSection( QPainter* painter, const QRect& rect_, int x, int width, const QModelIndex& index ); + QModelIndex indexAt( const QPoint& point, const QRect& rect, const QModelIndex& parent ) const; + QRect visualRect( const QModelIndex& index, const QRect& rect, const QModelIndex& parent ) const; + + NestedPartitionsMode m_nestedPartitionsMode; + + SelectionFilter canBeSelected; + + struct Item + { + qreal size; + QModelIndex index; + }; + inline QPair< QVector< Item >, qreal > computeItemsVector( const QModelIndex& parent ) const; + QPersistentModelIndex m_hoveredIndex; +}; + +#endif /* PARTITIONPREVIEW_H */ diff --git a/calamares/src/modules/partition/gui/PartitionDialogHelpers.cpp b/calamares/src/modules/partition/gui/PartitionDialogHelpers.cpp new file mode 100644 index 0000000..db1943a --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionDialogHelpers.cpp @@ -0,0 +1,201 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionDialogHelpers.h" + +#include "core/PartUtils.h" +#include "core/PartitionCoreModule.h" +#include "gui/CreatePartitionDialog.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" + +#include + +#include +#include +#include +#include +#include + +QStringList +standardMountPoints() +{ + QStringList mountPoints { "/", "/boot", "/home", "/opt", "/srv", "/usr", "/var" }; + if ( PartUtils::isEfiSystem() ) + { + mountPoints << Calamares::JobQueue::instance()->globalStorage()->value( "efiSystemPartition" ).toString(); + } + mountPoints.removeDuplicates(); + mountPoints.sort(); + return mountPoints; +} + +void +standardMountPoints( QComboBox& combo ) +{ + combo.clear(); + combo.lineEdit()->setPlaceholderText( QObject::tr( "(no mount point)" ) ); + combo.addItems( standardMountPoints() ); +} + +void +standardMountPoints( QComboBox& combo, const QString& selected ) +{ + standardMountPoints( combo ); + setSelectedMountPoint( combo, selected ); +} + +QString +selectedMountPoint( QComboBox& combo ) +{ + return combo.currentText(); +} + +void +setSelectedMountPoint( QComboBox& combo, const QString& selected ) +{ + if ( selected.isEmpty() ) + { + combo.setCurrentIndex( -1 ); // (no mount point) + } + else + { + for ( int i = 0; i < combo.count(); ++i ) + { + if ( selected == combo.itemText( i ) ) + { + combo.setCurrentIndex( i ); + return; + } + } + combo.addItem( selected ); + combo.setCurrentIndex( combo.count() - 1 ); + } +} + +bool +validateMountPoint( PartitionCoreModule* core, const QString& mountPoint, const QStringList& inUse, const QString& fileSystem, QLabel* label, QPushButton* button ) +{ + QString msg; + bool ok = true; + + if ( inUse.contains( mountPoint ) ) + { + msg = CreatePartitionDialog::tr( "Mountpoint already in use. Please select another one.", "@info" ); + ok = false; + } + else if ( !mountPoint.isEmpty() && !mountPoint.startsWith( '/' ) ) + { + msg = CreatePartitionDialog::tr( "Mountpoint must start with a /.", "@info" ); + ok = false; + } else { + // Validate the chosen filesystem + mountpoint combination. + FileSystem::Type selectedFsType; + PartUtils::canonicalFilesystemName( fileSystem, &selectedFsType ); + bool fsTypeIsAllowed = false; + if ( selectedFsType == FileSystem::Type::Unknown ) + { + fsTypeIsAllowed = true; + } + else + { + QList< FileSystem::Type > anyAllowedFsTypes = core->dirFSRestrictLayout().anyAllowedFSTypes(); + for ( auto& anyAllowedFsType : anyAllowedFsTypes ) + { + if ( selectedFsType == anyAllowedFsType ) + { + fsTypeIsAllowed = true; + break; + } + } + } + + bool fsTypeIsAllowedForMountPoint = false; + // We allow arbitrary unmountable filesystems here since an + // unmountable filesystem has no mount point associated with it, thus + // any filesystem restriction we'd find at this point would be + // irrelevant. + if ( selectedFsType == FileSystem::Type::Unknown || s_unmountableFS.contains( selectedFsType ) ) + { + fsTypeIsAllowedForMountPoint = true; + } + else + { + QList< FileSystem::Type > allowedFsTypes = core->dirFSRestrictLayout().allowedFSTypes( mountPoint, inUse, false ); + for ( auto& allowedFsType : allowedFsTypes ) + { + if ( selectedFsType == allowedFsType ) + { + fsTypeIsAllowedForMountPoint = true; + break; + } + } + } + + if ( !fsTypeIsAllowed ) { + msg = CreatePartitionDialog::tr( "Filesystem is prohibited by this distro. Consider selecting another one.", "@info" ); + ok = true; + } + else if ( !fsTypeIsAllowedForMountPoint ) { + msg = CreatePartitionDialog::tr( "Filesystem is prohibited for use on this mountpoint. Consider selecting a different filesystem or mountpoint.", "@info" ); + ok = true; + } + } + + if ( label ) + { + label->setText( msg ); + } + if ( button ) + { + button->setEnabled( ok ); + } + return ok; +} + + +PartitionTable::Flags +flagsFromList( const QListWidget& list ) +{ + PartitionTable::Flags flags; + + for ( int i = 0; i < list.count(); i++ ) + { + if ( list.item( i )->checkState() == Qt::Checked ) + { + flags |= static_cast< PartitionTable::Flag >( list.item( i )->data( Qt::UserRole ).toInt() ); + } + } + + return flags; +} + +void +setFlagList( QListWidget& list, PartitionTable::Flags available, PartitionTable::Flags checked ) +{ + int f = 1; + QString s; + while ( !( s = PartitionTable::flagName( static_cast< PartitionTable::Flag >( f ) ) ).isEmpty() ) + { + if ( available & f ) + { + QListWidgetItem* item = new QListWidgetItem( s ); + list.addItem( item ); + item->setFlags( Qt::ItemIsUserCheckable | Qt::ItemIsEnabled ); + item->setData( Qt::UserRole, f ); + item->setCheckState( ( checked & f ) ? Qt::Checked : Qt::Unchecked ); + } + + f <<= 1; + } +} diff --git a/calamares/src/modules/partition/gui/PartitionDialogHelpers.h b/calamares/src/modules/partition/gui/PartitionDialogHelpers.h new file mode 100644 index 0000000..4f77c3a --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionDialogHelpers.h @@ -0,0 +1,88 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITION_GUI_PARTITIONDIALOGHELPERS +#define PARTITION_GUI_PARTITIONDIALOGHELPERS + +#include +#include + +#include +#include + +class PartitionCoreModule; +class QPushButton; +class QComboBox; +class QLabel; +class QListWidget; + +static QSet< FileSystem::Type > s_unmountableFS( { FileSystem::Unformatted, + FileSystem::LinuxSwap, + FileSystem::Extended, + FileSystem::Unknown, + FileSystem::Lvm2_PV } ); + +/** + * Returns a list of standard mount points (e.g. /, /usr, ...). + * This also includes the EFI mount point if that is necessary + * on the target system. + */ +QStringList standardMountPoints(); + +/** + * Clears the combobox and fills it with "(no mount point)" + * and the elements of standardMountPoints(), above. + */ +void standardMountPoints( QComboBox& ); + +/** + * As above, but also sets the displayed mount point to @p selected, + * unless it is empty, in which case "(no mount point)" is chosen. + */ +void standardMountPoints( QComboBox&, const QString& selected ); + +/** + * Get the mount point selected in the combo box (which should + * have been set up with standardMountPoints(), above); this + * will map the topmost item (i.e. "(no mount point)") back + * to blank, to allow easy detection of no-mount-selected. + */ +QString selectedMountPoint( QComboBox& combo ); +static inline QString +selectedMountPoint( QComboBox* combo ) +{ + return selectedMountPoint( *combo ); +} + +void setSelectedMountPoint( QComboBox& combo, const QString& selected ); +static inline void +setSelectedMountPoint( QComboBox* combo, const QString& selected ) +{ + setSelectedMountPoint( *combo, selected ); +} + +/** @brief Validate a @p mountPoint and adjust the UI + * + * If @p mountPoint is valid -- unused and starts with a /, for instance -- + * then the button is enabled, label is cleared, and returns @c true. + * + * If it is not valid, returns @c false and sets the UI + * to explain why. + */ +bool validateMountPoint( PartitionCoreModule* core, const QString& mountPoint, const QStringList& inUse, const QString& fileSystem, QLabel* label, QPushButton* button ); + +/** + * Get the flags that have been checked in the list widget. + */ +PartitionTable::Flags flagsFromList( const QListWidget& list ); +void setFlagList( QListWidget& list, PartitionTable::Flags available, PartitionTable::Flags checked ); + +#endif diff --git a/calamares/src/modules/partition/gui/PartitionLabelsView.cpp b/calamares/src/modules/partition/gui/PartitionLabelsView.cpp new file mode 100644 index 0000000..b9e315e --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionLabelsView.cpp @@ -0,0 +1,617 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionLabelsView.h" + +#include "core/ColorUtils.h" +#include "core/PartitionModel.h" +#include "core/SizeUtils.h" +#include "core/KPMHelpers.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Units.h" + +#include +#include +#include + +// Qt +#include +#include +#include + +using namespace Calamares::Units; + +static const int LAYOUT_MARGIN = 4; +static const int LABEL_PARTITION_SQUARE_MARGIN = qMax( Calamares::defaultFontHeight() - 2, 18 ); +static const int LABELS_MARGIN = LABEL_PARTITION_SQUARE_MARGIN; +static const int CORNER_RADIUS = 2; + +static QStringList +buildUnknownDisklabelTexts( Device* dev ) +{ + QStringList texts = { QObject::tr( "Unpartitioned space or unknown partition table", "@info" ), + formatByteSize( dev->totalLogical() * dev->logicalSize() ) }; + return texts; +} + +static uint +getPartitionModelIndexFlags( const QModelIndex& index ) +{ + return static_cast< Partition* >( index.data( PartitionModel::PartitionPtrRole ).value< void* >() )->property( "_calamares_flags" ).toUInt(); +} + +PartitionLabelsView::PartitionLabelsView( QWidget* parent ) + : QAbstractItemView( parent ) + , m_canBeSelected( []( const QModelIndex& ) { return true; } ) + , m_extendedPartitionHidden( false ) +{ + setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Fixed ); + setFrameStyle( QFrame::NoFrame ); + setSelectionBehavior( QAbstractItemView::SelectRows ); + setSelectionMode( QAbstractItemView::SingleSelection ); + this->setObjectName( "partitionLabel" ); + setMouseTracking( true ); +} + +PartitionLabelsView::~PartitionLabelsView() {} + +QSize +PartitionLabelsView::minimumSizeHint() const +{ + return sizeHint(); +} + +QSize +PartitionLabelsView::sizeHint() const +{ + QAbstractItemModel* modl = model(); + if ( modl ) + { + return QSize( -1, LAYOUT_MARGIN + sizeForAllLabels( rect().width() ).height() ); + } + return QSize(); +} + +void +PartitionLabelsView::paintEvent( QPaintEvent* event ) +{ + Q_UNUSED( event ) + + QPainter painter( viewport() ); + painter.fillRect( rect(), palette().window() ); + painter.setRenderHint( QPainter::Antialiasing ); + + QRect lRect = labelsRect(); + + drawLabels( &painter, lRect, QModelIndex() ); +} + +QRect +PartitionLabelsView::labelsRect() const +{ + return rect().adjusted( 0, LAYOUT_MARGIN, 0, 0 ); +} + +static void +drawPartitionSquare( QPainter* painter, const QRect& rect, const QBrush& brush ) +{ + painter->fillRect( rect.adjusted( 1, 1, -1, -1 ), brush ); + painter->setRenderHint( QPainter::Antialiasing, true ); + painter->setPen( QPalette().shadow().color() ); + painter->translate( .5, .5 ); + painter->drawRoundedRect( rect.adjusted( 0, 0, -1, -1 ), CORNER_RADIUS, CORNER_RADIUS ); + painter->translate( -.5, -.5 ); +} + +static void +drawSelectionSquare( QPainter* painter, const QRect& rect, const QBrush& brush ) +{ + painter->save(); + painter->setPen( QPen( brush.color().darker(), 1 ) ); + QColor highlightColor = QPalette().highlight().color(); + highlightColor = highlightColor.lighter( 500 ); + highlightColor.setAlpha( 120 ); + painter->setBrush( highlightColor ); + painter->translate( .5, .5 ); + painter->drawRoundedRect( rect.adjusted( 0, 0, -1, -1 ), CORNER_RADIUS, CORNER_RADIUS ); + painter->translate( -.5, -.5 ); + painter->restore(); +} + +QModelIndexList +PartitionLabelsView::getIndexesToDraw( const QModelIndex& parent ) const +{ + QModelIndexList list; + + QAbstractItemModel* modl = model(); + if ( !modl ) + { + return list; + } + + for ( int row = 0; row < modl->rowCount( parent ); ++row ) + { + QModelIndex index = modl->index( row, 0, parent ); + + //HACK: horrible special casing follows. + // To save vertical space, we choose to hide short instances of free space. + // Arbitrary limit: 10MiB. + const qint64 maxHiddenB = 10_MiB; + if ( index.data( PartitionModel::IsFreeSpaceRole ).toBool() + && index.data( PartitionModel::SizeRole ).toLongLong() < maxHiddenB ) + { + continue; + } + + if ( !modl->hasChildren( index ) || !m_extendedPartitionHidden ) + { + list.append( index ); + } + + if ( modl->hasChildren( index ) ) + { + list.append( getIndexesToDraw( index ) ); + } + } + return list; +} + +QStringList +PartitionLabelsView::buildTexts( const QModelIndex& index ) const +{ + QString firstLine, secondLine; + + if ( index.data( PartitionModel::IsPartitionNewRole ).toBool() ) + { + QString label = index.data( PartitionModel::FileSystemLabelRole ).toString(); + + if ( !label.isEmpty() ) + { + firstLine = label; + } + else + { + QString mountPoint = index.sibling( index.row(), PartitionModel::MountPointColumn ).data().toString(); + if ( mountPoint == "/" ) + { + firstLine = m_customNewRootLabel.isEmpty() ? tr( "Root" ) : m_customNewRootLabel; + } + else if ( mountPoint == "/home" ) + { + firstLine = tr( "Home", "@label" ); + } + else if ( mountPoint == "/boot" ) + { + firstLine = tr( "Boot", "@label" ); + } + else if ( mountPoint.contains( "/efi" ) + && index.data( PartitionModel::FileSystemTypeRole ).toInt() == FileSystem::Fat32 ) + { + firstLine = tr( "EFI system", "@label" ); + } + else if ( index.data( PartitionModel::FileSystemTypeRole ).toInt() == FileSystem::Unformatted + && getPartitionModelIndexFlags( index ) & KPM_PARTITION_FLAG( BiosGrub ) ) + { + firstLine = tr("BIOS boot", "@label" ); + } + else if ( index.data( PartitionModel::FileSystemTypeRole ).toInt() == FileSystem::LinuxSwap ) + { + firstLine = tr( "Swap", "@label" ); + } + else if ( !mountPoint.isEmpty() ) + { + firstLine = tr( "New partition for %1", "@label" ).arg( mountPoint ); + } + else + { + firstLine = tr( "New partition", "@label" ); + } + } + } + else if ( index.data( PartitionModel::OsproberNameRole ).toString().isEmpty() ) + { + firstLine = index.data().toString(); + if ( firstLine.startsWith( "/dev/" ) ) + { + firstLine.remove( 0, 5 ); // "/dev/" + } + } + else + { + firstLine = index.data( PartitionModel::OsproberNameRole ).toString(); + } + + if ( index.data( PartitionModel::IsFreeSpaceRole ).toBool() + || index.data( PartitionModel::FileSystemTypeRole ).toInt() == FileSystem::Extended ) + { + secondLine = index.sibling( index.row(), PartitionModel::SizeColumn ).data().toString(); + } + else + { + //: size[number] filesystem[name] + secondLine = tr( "%1 %2" ) + .arg( index.sibling( index.row(), PartitionModel::SizeColumn ).data().toString() ) + .arg( index.sibling( index.row(), PartitionModel::FileSystemColumn ).data().toString() ); + } + + return { firstLine, secondLine }; +} + +void +PartitionLabelsView::drawLabels( QPainter* painter, const QRect& rect, const QModelIndex& parent ) +{ + PartitionModel* modl = qobject_cast< PartitionModel* >( model() ); + if ( !modl ) + { + return; + } + + const QModelIndexList indexesToDraw = getIndexesToDraw( parent ); + + int label_x = rect.x(); + int label_y = rect.y(); + for ( const QModelIndex& index : indexesToDraw ) + { + QStringList texts = buildTexts( index ); + + QSize labelSize = sizeForLabel( texts ); + + QColor labelColor = index.data( Qt::DecorationRole ).value< QColor >(); + + if ( label_x + labelSize.width() > rect.width() ) //wrap to new line if overflow + { + label_x = rect.x(); + label_y += labelSize.height() + labelSize.height() / 4; + } + + // Draw hover + if ( selectionMode() != QAbstractItemView::NoSelection && // no hover without selection + m_hoveredIndex.isValid() && index == m_hoveredIndex ) + { + painter->save(); + QRect labelRect( QPoint( label_x, label_y ), labelSize ); + labelRect.adjust( 0, -LAYOUT_MARGIN, 0, -2 * LAYOUT_MARGIN ); + painter->translate( 0.5, 0.5 ); + QRect hoverRect = labelRect.adjusted( 0, 0, -1, -1 ); + painter->setBrush( QPalette().window().color().lighter( 102 ) ); + painter->setPen( Qt::NoPen ); + painter->drawRoundedRect( hoverRect, CORNER_RADIUS, CORNER_RADIUS ); + + painter->translate( -0.5, -0.5 ); + painter->restore(); + } + + // Is this element the selected one? + bool sel = selectionMode() != QAbstractItemView::NoSelection && index.isValid() && selectionModel() + && !selectionModel()->selectedIndexes().isEmpty() && selectionModel()->selectedIndexes().first() == index; + + drawLabel( painter, texts, labelColor, QPoint( label_x, label_y ), sel ); + + label_x += labelSize.width() + LABELS_MARGIN; + } + + if ( !modl->rowCount() && !modl->device()->partitionTable() ) // No disklabel or unknown + { + QStringList texts = buildUnknownDisklabelTexts( modl->device() ); + QColor labelColor = ColorUtils::unknownDisklabelColor(); + drawLabel( painter, texts, labelColor, QPoint( rect.x(), rect.y() ), false /*can't be selected*/ ); + } +} + +QSize +PartitionLabelsView::sizeForAllLabels( int maxLineWidth ) const +{ + PartitionModel* modl = qobject_cast< PartitionModel* >( model() ); + if ( !modl ) + { + return QSize(); + } + + const QModelIndexList indexesToDraw = getIndexesToDraw( QModelIndex() ); + + int lineLength = 0; + int numLines = 1; + int singleLabelHeight = 0; + for ( const QModelIndex& index : indexesToDraw ) + { + QStringList texts = buildTexts( index ); + + QSize labelSize = sizeForLabel( texts ); + + if ( lineLength + labelSize.width() > maxLineWidth ) + { + numLines++; + lineLength = labelSize.width(); + } + else + { + lineLength += LABELS_MARGIN + labelSize.width(); + } + + singleLabelHeight = qMax( singleLabelHeight, labelSize.height() ); + } + + if ( !modl->rowCount() && !modl->device()->partitionTable() ) // Unknown or no disklabel + { + singleLabelHeight = sizeForLabel( buildUnknownDisklabelTexts( modl->device() ) ).height(); + } + + int totalHeight = numLines * singleLabelHeight + ( numLines - 1 ) * singleLabelHeight / 4; //spacings + + return QSize( maxLineWidth, totalHeight ); +} + +QSize +PartitionLabelsView::sizeForLabel( const QStringList& text ) const +{ + int vertOffset = 0; + int width = 0; + for ( const QString& textLine : text ) + { + QSize textSize = fontMetrics().size( Qt::TextSingleLine, textLine ); + + vertOffset += textSize.height(); + width = qMax( width, textSize.width() ); + } + width += LABEL_PARTITION_SQUARE_MARGIN; //for the color square + return QSize( width, vertOffset ); +} + +void +PartitionLabelsView::drawLabel( QPainter* painter, + const QStringList& text, + const QColor& color, + const QPoint& pos, + bool selected ) +{ + painter->setPen( Qt::black ); + int vertOffset = 0; + int width = 0; + for ( const QString& textLine : text ) + { + QSize textSize = painter->fontMetrics().size( Qt::TextSingleLine, textLine ); + painter->drawText( + pos.x() + LABEL_PARTITION_SQUARE_MARGIN, pos.y() + vertOffset + textSize.height() / 2, textLine ); + vertOffset += textSize.height(); + painter->setPen( Qt::gray ); + width = qMax( width, textSize.width() ); + } + + QRect partitionSquareRect( + pos.x(), pos.y() - 3, LABEL_PARTITION_SQUARE_MARGIN - 5, LABEL_PARTITION_SQUARE_MARGIN - 5 ); + drawPartitionSquare( painter, partitionSquareRect, color ); + + if ( selected ) + { + drawSelectionSquare( painter, partitionSquareRect.adjusted( 2, 2, -2, -2 ), color ); + } + + painter->setPen( Qt::black ); +} + +QModelIndex +PartitionLabelsView::indexAt( const QPoint& point ) const +{ + PartitionModel* modl = qobject_cast< PartitionModel* >( model() ); + if ( !modl ) + { + return QModelIndex(); + } + + const QModelIndexList indexesToDraw = getIndexesToDraw( QModelIndex() ); + + QRect rect = this->rect(); + int label_x = rect.x(); + int label_y = rect.y(); + for ( const QModelIndex& index : indexesToDraw ) + { + QStringList texts = buildTexts( index ); + + QSize labelSize = sizeForLabel( texts ); + + if ( label_x + labelSize.width() > rect.width() ) //wrap to new line if overflow + { + label_x = rect.x(); + label_y += labelSize.height() + labelSize.height() / 4; + } + + QRect labelRect( QPoint( label_x, label_y ), labelSize ); + if ( labelRect.contains( point ) ) + { + return index; + } + + label_x += labelSize.width() + LABELS_MARGIN; + } + + return QModelIndex(); +} + +QRect +PartitionLabelsView::visualRect( const QModelIndex& idx ) const +{ + PartitionModel* modl = qobject_cast< PartitionModel* >( model() ); + if ( !modl ) + { + return QRect(); + } + + const QModelIndexList indexesToDraw = getIndexesToDraw( QModelIndex() ); + + QRect rect = this->rect(); + int label_x = rect.x(); + int label_y = rect.y(); + for ( const QModelIndex& index : indexesToDraw ) + { + QStringList texts = buildTexts( index ); + + QSize labelSize = sizeForLabel( texts ); + + if ( label_x + labelSize.width() > rect.width() ) //wrap to new line if overflow + { + label_x = rect.x(); + label_y += labelSize.height() + labelSize.height() / 4; + } + + if ( idx.isValid() && idx == index ) + { + return QRect( QPoint( label_x, label_y ), labelSize ); + } + + label_x += labelSize.width() + LABELS_MARGIN; + } + + return QRect(); +} + +QRegion +PartitionLabelsView::visualRegionForSelection( const QItemSelection& selection ) const +{ + Q_UNUSED( selection ) + + return QRegion(); +} + +int +PartitionLabelsView::horizontalOffset() const +{ + return 0; +} + +int +PartitionLabelsView::verticalOffset() const +{ + return 0; +} + +void +PartitionLabelsView::scrollTo( const QModelIndex& index, ScrollHint hint ) +{ + Q_UNUSED( index ) + Q_UNUSED( hint ) +} + +void +PartitionLabelsView::setCustomNewRootLabel( const QString& text ) +{ + m_customNewRootLabel = text; + viewport()->repaint(); +} + +void +PartitionLabelsView::setSelectionModel( QItemSelectionModel* selectionModel ) +{ + QAbstractItemView::setSelectionModel( selectionModel ); + connect( selectionModel, &QItemSelectionModel::selectionChanged, this, [ = ] { viewport()->repaint(); } ); +} + +void +PartitionLabelsView::setSelectionFilter( SelectionFilter canBeSelected ) +{ + m_canBeSelected = canBeSelected; +} + +void +PartitionLabelsView::setExtendedPartitionHidden( bool hidden ) +{ + m_extendedPartitionHidden = hidden; +} + +QModelIndex +PartitionLabelsView::moveCursor( CursorAction cursorAction, Qt::KeyboardModifiers modifiers ) +{ + Q_UNUSED( cursorAction ) + Q_UNUSED( modifiers ) + + return QModelIndex(); +} + +bool +PartitionLabelsView::isIndexHidden( const QModelIndex& index ) const +{ + Q_UNUSED( index ) + + return false; +} + +void +PartitionLabelsView::setSelection( const QRect& rect, QItemSelectionModel::SelectionFlags flags ) +{ + QModelIndex eventIndex = indexAt( rect.topLeft() ); + if ( m_canBeSelected( eventIndex ) ) + { + selectionModel()->select( eventIndex, flags ); + } +} + +void +PartitionLabelsView::mouseMoveEvent( QMouseEvent* event ) +{ + QModelIndex candidateIndex = indexAt( event->pos() ); + QPersistentModelIndex oldHoveredIndex = m_hoveredIndex; + if ( candidateIndex.isValid() ) + { + m_hoveredIndex = candidateIndex; + } + else + { + m_hoveredIndex = QModelIndex(); + QGuiApplication::restoreOverrideCursor(); + } + + if ( oldHoveredIndex != m_hoveredIndex ) + { + if ( m_hoveredIndex.isValid() && !m_canBeSelected( m_hoveredIndex ) ) + { + QGuiApplication::setOverrideCursor( Qt::ForbiddenCursor ); + } + else + { + QGuiApplication::restoreOverrideCursor(); + } + + viewport()->repaint(); + } +} + +void +PartitionLabelsView::leaveEvent( QEvent* event ) +{ + Q_UNUSED( event ) + + QGuiApplication::restoreOverrideCursor(); + if ( m_hoveredIndex.isValid() ) + { + m_hoveredIndex = QModelIndex(); + viewport()->repaint(); + } +} + +void +PartitionLabelsView::mousePressEvent( QMouseEvent* event ) +{ + QModelIndex candidateIndex = indexAt( event->pos() ); + if ( m_canBeSelected( candidateIndex ) ) + { + QAbstractItemView::mousePressEvent( event ); + } + else + { + event->accept(); + } +} + +void +PartitionLabelsView::updateGeometries() +{ + updateGeometry(); //get a new rect() for redrawing all the labels +} diff --git a/calamares/src/modules/partition/gui/PartitionLabelsView.h b/calamares/src/modules/partition/gui/PartitionLabelsView.h new file mode 100644 index 0000000..9b5a277 --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionLabelsView.h @@ -0,0 +1,84 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONLABELSVIEW_H +#define PARTITIONLABELSVIEW_H + +#include "PartitionViewSelectionFilter.h" + +#include + +/** + * A Qt model view which displays colored labels for partitions. + * + * It has been created to be used with a PartitionModel instance, but does not + * call any PartitionModel-specific methods: it should be usable with other + * models as long as they provide the same roles PartitionModel provides. + */ +class PartitionLabelsView : public QAbstractItemView +{ + Q_OBJECT +public: + explicit PartitionLabelsView( QWidget* parent = nullptr ); + ~PartitionLabelsView() override; + + QSize minimumSizeHint() const override; + + QSize sizeHint() const override; + + void paintEvent( QPaintEvent* event ) override; + + // QAbstractItemView API + QModelIndex indexAt( const QPoint& point ) const override; + QRect visualRect( const QModelIndex& idx ) const override; + void scrollTo( const QModelIndex& index, ScrollHint hint = EnsureVisible ) override; + + void setCustomNewRootLabel( const QString& text ); + + void setSelectionModel( QItemSelectionModel* selectionModel ) override; + + void setSelectionFilter( SelectionFilter canBeSelected ); + + void setExtendedPartitionHidden( bool hidden ); + +protected: + // QAbstractItemView API + QRegion visualRegionForSelection( const QItemSelection& selection ) const override; + int horizontalOffset() const override; + int verticalOffset() const override; + bool isIndexHidden( const QModelIndex& index ) const override; + QModelIndex moveCursor( CursorAction cursorAction, Qt::KeyboardModifiers modifiers ) override; + void setSelection( const QRect& rect, QItemSelectionModel::SelectionFlags flags ) override; + + void mouseMoveEvent( QMouseEvent* event ) override; + void leaveEvent( QEvent* event ) override; + void mousePressEvent( QMouseEvent* event ) override; + +protected slots: + void updateGeometries() override; + +private: + QRect labelsRect() const; + void drawLabels( QPainter* painter, const QRect& rect, const QModelIndex& parent ); + QSize sizeForAllLabels( int maxLineWidth ) const; + QSize sizeForLabel( const QStringList& text ) const; + void drawLabel( QPainter* painter, const QStringList& text, const QColor& color, const QPoint& pos, bool selected ); + QModelIndexList getIndexesToDraw( const QModelIndex& parent ) const; + QStringList buildTexts( const QModelIndex& index ) const; + + SelectionFilter m_canBeSelected; + bool m_extendedPartitionHidden; + + QString m_customNewRootLabel; + QPersistentModelIndex m_hoveredIndex; +}; + +#endif // PARTITIONLABELSVIEW_H diff --git a/calamares/src/modules/partition/gui/PartitionPage.cpp b/calamares/src/modules/partition/gui/PartitionPage.cpp new file mode 100644 index 0000000..a5f4365 --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionPage.cpp @@ -0,0 +1,698 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2018 Andrius Štikonas + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionPage.h" + +// Local +#include "Config.h" +#include "core/BootLoaderModel.h" +#include "core/DeviceModel.h" +#include "core/KPMHelpers.h" +#include "core/PartUtils.h" +#include "core/PartitionCoreModule.h" +#include "core/PartitionInfo.h" +#include "core/PartitionModel.h" +#include "gui/CreatePartitionDialog.h" +#include "gui/CreateVolumeGroupDialog.h" +#include "gui/EditExistingPartitionDialog.h" +#include "gui/ResizeVolumeGroupDialog.h" +#include "gui/ScanningDialog.h" + +#include "ui_CreatePartitionTableDialog.h" +#include "ui_PartitionPage.h" + +#include "Branding.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "widgets/TranslationFix.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +PartitionPage::PartitionPage( PartitionCoreModule* core, const Config & config, QWidget* parent ) + : QWidget( parent ) + , m_ui( new Ui_PartitionPage ) + , m_core( core ) + , m_lastSelectedBootLoaderIndex( -1 ) + , m_isEfi( PartUtils::isEfiSystem() ) +{ + if ( config.installChoice() != Config::InstallChoice::Manual ) + { + cWarning() << "Manual partitioning page created without user choosing manual-partitioning."; + } + + m_ui->setupUi( this ); + m_ui->partitionLabelsView->setVisible( + Calamares::JobQueue::instance()->globalStorage()->value( "alwaysShowPartitionLabels" ).toBool() ); + m_ui->deviceComboBox->setModel( m_core->deviceModel() ); + m_ui->bootLoaderComboBox->setModel( m_core->bootLoaderModel() ); + connect( + m_core->bootLoaderModel(), &QAbstractItemModel::modelReset, this, &PartitionPage::restoreSelectedBootLoader ); + PartitionBarsView::NestedPartitionsMode mode + = Calamares::JobQueue::instance()->globalStorage()->value( "drawNestedPartitions" ).toBool() + ? PartitionBarsView::DrawNestedPartitions + : PartitionBarsView::NoNestedPartitions; + m_ui->partitionBarsView->setNestedPartitionsMode( mode ); + m_ui->lvmButtonPanel->setVisible( config.isLVMEnabled() ); + + updateButtons(); + updateBootLoaderInstallPath(); + + updateFromCurrentDevice(); + + connect( m_ui->deviceComboBox, &QComboBox::currentTextChanged, this, &PartitionPage::updateFromCurrentDevice ); + connect( m_ui->bootLoaderComboBox, + QOverload< int >::of( &QComboBox::activated ), + this, + &PartitionPage::updateSelectedBootLoaderIndex ); + connect( + m_ui->bootLoaderComboBox, &QComboBox::currentTextChanged, this, &PartitionPage::updateBootLoaderInstallPath ); + + connect( m_core, &PartitionCoreModule::isDirtyChanged, m_ui->revertButton, &QWidget::setEnabled ); + + connect( + m_ui->partitionTreeView, &QAbstractItemView::doubleClicked, this, &PartitionPage::onPartitionViewActivated ); + connect( m_ui->revertButton, &QAbstractButton::clicked, this, &PartitionPage::onRevertClicked ); + connect( m_ui->newVolumeGroupButton, &QAbstractButton::clicked, this, &PartitionPage::onNewVolumeGroupClicked ); + connect( + m_ui->resizeVolumeGroupButton, &QAbstractButton::clicked, this, &PartitionPage::onResizeVolumeGroupClicked ); + connect( m_ui->deactivateVolumeGroupButton, + &QAbstractButton::clicked, + this, + &PartitionPage::onDeactivateVolumeGroupClicked ); + connect( + m_ui->removeVolumeGroupButton, &QAbstractButton::clicked, this, &PartitionPage::onRemoveVolumeGroupClicked ); + connect( + m_ui->newPartitionTableButton, &QAbstractButton::clicked, this, &PartitionPage::onNewPartitionTableClicked ); + connect( m_ui->createButton, &QAbstractButton::clicked, this, &PartitionPage::onCreateClicked ); + connect( m_ui->editButton, &QAbstractButton::clicked, this, &PartitionPage::onEditClicked ); + connect( m_ui->deleteButton, &QAbstractButton::clicked, this, &PartitionPage::onDeleteClicked ); + + if ( m_isEfi ) + { + m_ui->bootLoaderComboBox->hide(); + m_ui->label_3->hide(); + } + + CALAMARES_RETRANSLATE( + m_ui->retranslateUi( this ); + m_core->bootLoaderModel()->update(); // Need to re-translate entries in the combo-box + ); +} + +PartitionPage::~PartitionPage() {} + +void +PartitionPage::updateButtons() +{ + bool allow_create = false, allow_create_table = false, allow_edit = false, allow_delete = false; + bool currentDeviceIsVG = false, isDeactivable = false; + bool isRemovable = false, isVGdeactivated = false; + + QModelIndex index = m_ui->partitionTreeView->currentIndex(); + if ( index.isValid() ) + { + const PartitionModel* model = static_cast< const PartitionModel* >( index.model() ); + Q_ASSERT( model ); + Partition* partition = model->partitionForIndex( index ); + Q_ASSERT( partition ); + const bool isFree = Calamares::Partition::isPartitionFreeSpace( partition ); + const bool isExtended = partition->roles().has( PartitionRole::Extended ); + + // An extended partition can have a "free space" child; that one does + // not count as a real child. If there are more children, at least one + // is a real one and we should not allow the extended partition to be + // deleted. + const bool hasChildren = isExtended + && ( partition->children().length() > 1 + || ( partition->children().length() == 1 + && !Calamares::Partition::isPartitionFreeSpace( partition->children().at( 0 ) ) ) ); + + const bool isInVG = m_core->isInVG( partition ); + + allow_create = isFree; + + // Keep it simple for now: do not support editing extended partitions as + // it does not work with our current edit implementation which is + // actually remove + add. This would not work with extended partitions + // because they need to be created *before* creating logical partitions + // inside them, so an edit must be applied without altering the job + // order. + // TODO: See if LVM PVs can be edited in Calamares + allow_edit = !isFree && !isExtended; + allow_delete = !isFree && !isInVG && !hasChildren; + } + + if ( m_ui->deviceComboBox->currentIndex() >= 0 ) + { + Device* device = nullptr; + QModelIndex deviceIndex = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + if ( deviceIndex.isValid() ) + { + device = m_core->deviceModel()->deviceForIndex( deviceIndex ); + } + if ( !device ) + { + cWarning() << "Device for updateButtons is nullptr"; + } + else if ( device->type() != Device::Type::LVM_Device ) + { + allow_create_table = true; + + if ( device->type() == Device::Type::SoftwareRAID_Device + && static_cast< SoftwareRAID* >( device )->status() == SoftwareRAID::Status::Inactive ) + { + allow_create_table = false; + allow_create = false; + } + } + else + { + currentDeviceIsVG = true; + + LvmDevice* lvmDevice = dynamic_cast< LvmDevice* >( m_core->deviceModel()->deviceForIndex( deviceIndex ) ); + + isDeactivable = DeactivateVolumeGroupOperation::isDeactivatable( lvmDevice ); + isRemovable = RemoveVolumeGroupOperation::isRemovable( lvmDevice ); + + isVGdeactivated = m_core->isVGdeactivated( lvmDevice ); + + if ( isVGdeactivated ) + { + m_ui->revertButton->setEnabled( true ); + } + } + } + + m_ui->createButton->setEnabled( allow_create ); + m_ui->editButton->setEnabled( allow_edit ); + m_ui->deleteButton->setEnabled( allow_delete ); + m_ui->newPartitionTableButton->setEnabled( allow_create_table ); + m_ui->resizeVolumeGroupButton->setEnabled( currentDeviceIsVG && !isVGdeactivated ); + m_ui->deactivateVolumeGroupButton->setEnabled( currentDeviceIsVG && isDeactivable && !isVGdeactivated ); + m_ui->removeVolumeGroupButton->setEnabled( currentDeviceIsVG && isRemovable ); +} + +void +PartitionPage::onNewPartitionTableClicked() +{ + QModelIndex index = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + Q_ASSERT( index.isValid() ); + Device* device = m_core->deviceModel()->deviceForIndex( index ); + + QPointer< QDialog > dlg = new QDialog( this ); + Ui_CreatePartitionTableDialog ui; + ui.setupUi( dlg.data() ); + QString areYouSure = tr( "Are you sure you want to create a new partition table on %1?" ).arg( device->name() ); + if ( PartUtils::isEfiSystem() ) + { + ui.gptRadioButton->setChecked( true ); + } + else + { + ui.mbrRadioButton->setChecked( true ); + } + + ui.areYouSureLabel->setText( areYouSure ); + if ( dlg->exec() == QDialog::Accepted ) + { + PartitionTable::TableType type = ui.mbrRadioButton->isChecked() ? PartitionTable::msdos : PartitionTable::gpt; + m_core->createPartitionTable( device, type ); + } + delete dlg; + // PartionModelReset isn't emitted after createPartitionTable, so we have to manually update + // the bootLoader index after the reset. + updateBootLoaderIndex(); +} + +bool +PartitionPage::checkCanCreate( Device* device ) +{ + auto table = device->partitionTable(); + + if ( KPMHelpers::isMSDOSPartition( table->type() ) ) + { + cDebug() << "Checking MSDOS partition" << table->numPrimaries() << "primaries, max" << table->maxPrimaries(); + + if ( ( table->numPrimaries() >= table->maxPrimaries() ) && !table->hasExtended() ) + { + QMessageBox mb( + QMessageBox::Warning, + tr( "Can not create new partition" ), + tr( "The partition table on %1 already has %2 primary partitions, and no more can be added. " + "Please remove one primary partition and add an extended partition, instead." ) + .arg( device->name() ) + .arg( table->numPrimaries() ), + QMessageBox::Ok ); + Calamares::fixButtonLabels( &mb ); + mb.exec(); + return false; + } + return true; + } + else + { + return true; // GPT is fine + } +} + +void +PartitionPage::onNewVolumeGroupClicked() +{ + QString vgName; + QVector< const Partition* > selectedPVs; + qint64 peSize = 4; + + QVector< const Partition* > availablePVs; + + for ( const Partition* p : m_core->lvmPVs() ) + { + if ( !m_core->isInVG( p ) ) + { + availablePVs << p; + } + } + + QPointer< CreateVolumeGroupDialog > dlg + = new CreateVolumeGroupDialog( vgName, selectedPVs, availablePVs, peSize, this ); + + if ( dlg->exec() == QDialog::Accepted ) + { + QModelIndex partitionIndex = m_ui->partitionTreeView->currentIndex(); + + if ( partitionIndex.isValid() ) + { + const PartitionModel* model = static_cast< const PartitionModel* >( partitionIndex.model() ); + Q_ASSERT( model ); + Partition* partition = model->partitionForIndex( partitionIndex ); + Q_ASSERT( partition ); + + // Disable delete button if current partition was selected to be in VG + // TODO: Should Calamares edit LVM PVs which are in VGs? + if ( selectedPVs.contains( partition ) ) + { + m_ui->deleteButton->setEnabled( false ); + } + } + + QModelIndex deviceIndex = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + Q_ASSERT( deviceIndex.isValid() ); + + QVariant previousIndexDeviceData = m_core->deviceModel()->data( deviceIndex, Qt::ToolTipRole ); + + // Creating new VG + m_core->createVolumeGroup( vgName, selectedPVs, peSize ); + + // As createVolumeGroup method call resets deviceModel, + // is needed to set the current index in deviceComboBox as the previous one + int previousIndex = m_ui->deviceComboBox->findData( previousIndexDeviceData, Qt::ToolTipRole ); + + m_ui->deviceComboBox->setCurrentIndex( ( previousIndex < 0 ) ? 0 : previousIndex ); + updateFromCurrentDevice(); + } + + delete dlg; +} + +void +PartitionPage::onResizeVolumeGroupClicked() +{ + QModelIndex deviceIndex = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + LvmDevice* device = dynamic_cast< LvmDevice* >( m_core->deviceModel()->deviceForIndex( deviceIndex ) ); + + Q_ASSERT( device && device->type() == Device::Type::LVM_Device ); + + QVector< const Partition* > availablePVs; + QVector< const Partition* > selectedPVs; + + for ( const Partition* p : m_core->lvmPVs() ) + { + if ( !m_core->isInVG( p ) ) + { + availablePVs << p; + } + } + + QPointer< ResizeVolumeGroupDialog > dlg = new ResizeVolumeGroupDialog( device, availablePVs, selectedPVs, this ); + + if ( dlg->exec() == QDialog::Accepted ) + { + m_core->resizeVolumeGroup( device, selectedPVs ); + } + + delete dlg; +} + +void +PartitionPage::onDeactivateVolumeGroupClicked() +{ + QModelIndex deviceIndex = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + LvmDevice* device = dynamic_cast< LvmDevice* >( m_core->deviceModel()->deviceForIndex( deviceIndex ) ); + + Q_ASSERT( device && device->type() == Device::Type::LVM_Device ); + + m_core->deactivateVolumeGroup( device ); + + updateFromCurrentDevice(); + + PartitionModel* model = m_core->partitionModelForDevice( device ); + model->update(); +} + +void +PartitionPage::onRemoveVolumeGroupClicked() +{ + QModelIndex deviceIndex = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + LvmDevice* device = dynamic_cast< LvmDevice* >( m_core->deviceModel()->deviceForIndex( deviceIndex ) ); + + Q_ASSERT( device && device->type() == Device::Type::LVM_Device ); + + m_core->removeVolumeGroup( device ); +} + +void +PartitionPage::onCreateClicked() +{ + QModelIndex index = m_ui->partitionTreeView->currentIndex(); + Q_ASSERT( index.isValid() ); + + const PartitionModel* model = static_cast< const PartitionModel* >( index.model() ); + Partition* partition = model->partitionForIndex( index ); + Q_ASSERT( partition ); + + if ( !checkCanCreate( model->device() ) ) + { + return; + } + + QPointer< CreatePartitionDialog > dlg = new CreatePartitionDialog( + m_core, model->device(), CreatePartitionDialog::FreeSpace { partition }, getCurrentUsedMountpoints(), this ); + if ( dlg->exec() == QDialog::Accepted ) + { + Partition* newPart = dlg->getNewlyCreatedPartition(); + m_core->createPartition( model->device(), newPart, dlg->newFlags() ); + } + delete dlg; +} + +void +PartitionPage::onEditClicked() +{ + QModelIndex index = m_ui->partitionTreeView->currentIndex(); + Q_ASSERT( index.isValid() ); + + const PartitionModel* model = static_cast< const PartitionModel* >( index.model() ); + Partition* partition = model->partitionForIndex( index ); + Q_ASSERT( partition ); + + if ( Calamares::Partition::isPartitionNew( partition ) ) + { + updatePartitionToCreate( model->device(), partition ); + } + else + { + editExistingPartition( model->device(), partition ); + } +} + +void +PartitionPage::onDeleteClicked() +{ + QModelIndex index = m_ui->partitionTreeView->currentIndex(); + Q_ASSERT( index.isValid() ); + + const PartitionModel* model = static_cast< const PartitionModel* >( index.model() ); + Partition* partition = model->partitionForIndex( index ); + Q_ASSERT( partition ); + + m_core->deletePartition( model->device(), partition ); +} + + +void +PartitionPage::onRevertClicked() +{ + ScanningDialog::run( + QtConcurrent::run( + [ this ] + { + QMutexLocker locker( &m_revertMutex ); + + int oldIndex = m_ui->deviceComboBox->currentIndex(); + m_core->revertAllDevices(); + m_ui->deviceComboBox->setCurrentIndex( ( oldIndex < 0 ) ? 0 : oldIndex ); + updateFromCurrentDevice(); + } ), + [ this ] + { + m_lastSelectedBootLoaderIndex = -1; + if ( m_ui->bootLoaderComboBox->currentIndex() < 0 ) + { + m_ui->bootLoaderComboBox->setCurrentIndex( 0 ); + } + }, + this ); +} + +void +PartitionPage::onPartitionViewActivated() +{ + QModelIndex index = m_ui->partitionTreeView->currentIndex(); + if ( !index.isValid() ) + { + return; + } + + const PartitionModel* model = static_cast< const PartitionModel* >( index.model() ); + Q_ASSERT( model ); + Partition* partition = model->partitionForIndex( index ); + Q_ASSERT( partition ); + + // Use the buttons to trigger the actions so that they do nothing if they + // are disabled. Alternatively, the code could use QAction to centralize, + // but I don't expect there will be other occurences of triggering the same + // action from multiple UI elements in this page, so it does not feel worth + // the price. + if ( Calamares::Partition::isPartitionFreeSpace( partition ) ) + { + m_ui->createButton->click(); + } + else + { + m_ui->editButton->click(); + } +} + +void +PartitionPage::updatePartitionToCreate( Device* device, Partition* partition ) +{ + QStringList mountPoints = getCurrentUsedMountpoints(); + mountPoints.removeOne( PartitionInfo::mountPoint( partition ) ); + + QPointer< CreatePartitionDialog > dlg + = new CreatePartitionDialog( m_core, device, CreatePartitionDialog::FreshPartition { partition }, mountPoints, this ); + if ( dlg->exec() == QDialog::Accepted ) + { + Partition* newPartition = dlg->getNewlyCreatedPartition(); + m_core->deletePartition( device, partition ); + m_core->createPartition( device, newPartition, dlg->newFlags() ); + } + delete dlg; +} + +void +PartitionPage::editExistingPartition( Device* device, Partition* partition ) +{ + QStringList mountPoints = getCurrentUsedMountpoints(); + mountPoints.removeOne( PartitionInfo::mountPoint( partition ) ); + + QPointer< EditExistingPartitionDialog > dlg + = new EditExistingPartitionDialog( m_core, device, partition, mountPoints, this ); + if ( dlg->exec() == QDialog::Accepted ) + { + dlg->applyChanges( m_core ); + } + delete dlg; + + updateBootLoaderInstallPath(); +} + +void +PartitionPage::updateBootLoaderInstallPath() +{ + if ( m_isEfi || !m_ui->bootLoaderComboBox->isVisible() ) + { + return; + } + + QVariant var = m_ui->bootLoaderComboBox->currentData( BootLoaderModel::BootLoaderPathRole ); + if ( !var.isValid() ) + { + return; + } + cDebug() << "PartitionPage::updateBootLoaderInstallPath" << var.toString(); + m_core->setBootLoaderInstallPath( var.toString() ); +} + +void +PartitionPage::updateSelectedBootLoaderIndex() +{ + m_lastSelectedBootLoaderIndex = m_ui->bootLoaderComboBox->currentIndex(); + cDebug() << "Selected bootloader index" << m_lastSelectedBootLoaderIndex; +} + +void +PartitionPage::restoreSelectedBootLoader() +{ + Calamares::restoreSelectedBootLoader( *( m_ui->bootLoaderComboBox ), m_core->bootLoaderInstallPath() ); +} + +void +PartitionPage::reconcileSelections() +{ + QModelIndex selectedIndex = m_ui->partitionBarsView->selectionModel()->currentIndex(); + selectedIndex = selectedIndex.sibling( selectedIndex.row(), 0 ); + m_ui->partitionBarsView->setCurrentIndex( selectedIndex ); + m_ui->partitionLabelsView->setCurrentIndex( selectedIndex ); +} + +void +PartitionPage::updateFromCurrentDevice() +{ + QModelIndex index = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + if ( !index.isValid() ) + { + return; + } + + Device* device = m_core->deviceModel()->deviceForIndex( index ); + + QAbstractItemModel* oldModel = m_ui->partitionTreeView->model(); + if ( oldModel ) + { + disconnect( oldModel, nullptr, this, nullptr ); + } + + PartitionModel* model = m_core->partitionModelForDevice( device ); + m_ui->partitionBarsView->setModel( model ); + m_ui->partitionLabelsView->setModel( model ); + m_ui->partitionTreeView->setModel( model ); + m_ui->partitionTreeView->expandAll(); + + // Make all views use the same selection model. + if ( m_ui->partitionBarsView->selectionModel() != m_ui->partitionTreeView->selectionModel() + || m_ui->partitionBarsView->selectionModel() != m_ui->partitionLabelsView->selectionModel() ) + { + // Tree view + QItemSelectionModel* selectionModel = m_ui->partitionTreeView->selectionModel(); + m_ui->partitionTreeView->setSelectionModel( m_ui->partitionBarsView->selectionModel() ); + selectionModel->deleteLater(); + + // Labels view + selectionModel = m_ui->partitionLabelsView->selectionModel(); + m_ui->partitionLabelsView->setSelectionModel( m_ui->partitionBarsView->selectionModel() ); + selectionModel->deleteLater(); + } + + // This is necessary because even with the same selection model it might happen that + // a !=0 column is selected in the tree view, which for some reason doesn't trigger a + // timely repaint in the bars view. + connect( m_ui->partitionBarsView->selectionModel(), + &QItemSelectionModel::currentChanged, + this, + &PartitionPage::reconcileSelections, + Qt::UniqueConnection ); + + // Must be done here because we need to have a model set to define + // individual column resize mode + QHeaderView* header = m_ui->partitionTreeView->header(); + header->setSectionResizeMode( QHeaderView::ResizeToContents ); + header->setSectionResizeMode( 0, QHeaderView::Stretch ); + + updateButtons(); + // Establish connection here because selection model is destroyed when + // model changes + connect( m_ui->partitionTreeView->selectionModel(), + &QItemSelectionModel::currentChanged, + [ this ]( const QModelIndex&, const QModelIndex& ) { updateButtons(); } ); + connect( model, &QAbstractItemModel::modelReset, this, &PartitionPage::onPartitionModelReset ); +} + +void +PartitionPage::onPartitionModelReset() +{ + m_ui->partitionTreeView->expandAll(); + updateButtons(); + updateBootLoaderIndex(); +} + +void +PartitionPage::updateBootLoaderIndex() +{ + // set bootloader back to user selected index + if ( m_lastSelectedBootLoaderIndex >= 0 && m_ui->bootLoaderComboBox->count() ) + { + m_ui->bootLoaderComboBox->setCurrentIndex( m_lastSelectedBootLoaderIndex ); + } +} + +QStringList +PartitionPage::getCurrentUsedMountpoints() +{ + QModelIndex index = m_core->deviceModel()->index( m_ui->deviceComboBox->currentIndex(), 0 ); + if ( !index.isValid() ) + { + return QStringList(); + } + + Device* device = m_core->deviceModel()->deviceForIndex( index ); + QStringList mountPoints; + + for ( Partition* partition : device->partitionTable()->children() ) + { + const QString& mountPoint = PartitionInfo::mountPoint( partition ); + if ( !mountPoint.isEmpty() ) + { + mountPoints << mountPoint; + } + } + + return mountPoints; +} + +int +PartitionPage::selectedDeviceIndex() +{ + return m_ui->deviceComboBox->currentIndex(); +} + +void +PartitionPage::selectDeviceByIndex( int index ) +{ + m_ui->deviceComboBox->setCurrentIndex( index ); +} diff --git a/calamares/src/modules/partition/gui/PartitionPage.h b/calamares/src/modules/partition/gui/PartitionPage.h new file mode 100644 index 0000000..85021d5 --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionPage.h @@ -0,0 +1,90 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONPAGE_H +#define PARTITIONPAGE_H + +#include +#include +#include + +class Config; +class PartitionCoreModule; +class Ui_PartitionPage; + +class Device; +class Partition; + +/** + * The user interface for the module. + * + * Shows the information exposed by PartitionCoreModule and asks it to schedule + * jobs according to user actions. + */ +class PartitionPage : public QWidget +{ + Q_OBJECT +public: + explicit PartitionPage( PartitionCoreModule* core, const Config & config, QWidget* parent = nullptr ); + ~PartitionPage() override; + + void onRevertClicked(); + + int selectedDeviceIndex(); + void selectDeviceByIndex( int index ); + +private Q_SLOTS: + /// @brief Update everything when the base device changes + void updateFromCurrentDevice(); + /// @brief Update when the selected device for boot loader changes + void updateBootLoaderInstallPath(); + /// @brief Explicitly selected boot loader path + void updateSelectedBootLoaderIndex(); + /// @brief After boot loader model changes, try to preserve previously set value + void restoreSelectedBootLoader(); + /// @brief Make the selections in each widget match + void reconcileSelections(); + +private: + QScopedPointer< Ui_PartitionPage > m_ui; + PartitionCoreModule* m_core; + void updateButtons(); + void onNewPartitionTableClicked(); + void onNewVolumeGroupClicked(); + void onResizeVolumeGroupClicked(); + void onDeactivateVolumeGroupClicked(); + void onRemoveVolumeGroupClicked(); + void onCreateClicked(); + void onEditClicked(); + void onDeleteClicked(); + void onPartitionViewActivated(); + void onPartitionModelReset(); + + void updatePartitionToCreate( Device*, Partition* ); + void editExistingPartition( Device*, Partition* ); + void updateBootLoaderIndex(); + + /** + * @brief Check if a new partition can be created (as primary) on the device. + * + * Returns true if a new partition can be created on the device. Provides + * a warning popup and returns false if it cannot. + */ + bool checkCanCreate( Device* ); + + QStringList getCurrentUsedMountpoints(); + + QMutex m_revertMutex; + int m_lastSelectedBootLoaderIndex; + bool m_isEfi; +}; + +#endif // PARTITIONPAGE_H diff --git a/calamares/src/modules/partition/gui/PartitionPage.ui b/calamares/src/modules/partition/gui/PartitionPage.ui new file mode 100644 index 0000000..7de478c --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionPage.ui @@ -0,0 +1,243 @@ + + + +SPDX-FileCopyrightText: 2014 Aurélien Gâteau <agateau@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + PartitionPage + + + + 0 + 0 + 684 + 327 + + + + Form + + + + + + + + Storage de&vice: + + + deviceComboBox + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + false + + + &Revert All Changes + + + + + + + + + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + false + + + false + + + + + + + + + New Partition &Table + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cre&ate + + + + + + + &Edit + + + + + + + &Delete + + + + + + + + + + + + New Volume Group + + + + + + + Resize Volume Group + + + + + + + Deactivate Volume Group + + + + + + + Remove Volume Group + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 24 + + + + + + + + + + I&nstall boot loader on: + + + bootLoaderComboBox + + + + + + + QComboBox::AdjustToContents + + + + + + + Qt::Horizontal + + + + 40 + 1 + + + + + + + + + + + PartitionBarsView + QFrame +
    gui/PartitionBarsView.h
    + 1 +
    + + PartitionLabelsView + QFrame +
    gui/PartitionLabelsView.h
    + 1 +
    +
    + + deviceComboBox + revertButton + partitionTreeView + newPartitionTableButton + createButton + editButton + deleteButton + bootLoaderComboBox + + + +
    diff --git a/calamares/src/modules/partition/gui/PartitionSizeController.cpp b/calamares/src/modules/partition/gui/PartitionSizeController.cpp new file mode 100644 index 0000000..c21ebd1 --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionSizeController.cpp @@ -0,0 +1,221 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "gui/PartitionSizeController.h" + +#include "core/ColorUtils.h" +#include "core/KPMHelpers.h" + +#include "utils/Units.h" + +// Qt +#include + +// KPMcore +#include +#include + +// stdc++ +#include + +PartitionSizeController::PartitionSizeController( QObject* parent ) + : QObject( parent ) +{ +} + +void +PartitionSizeController::init( Device* device, Partition* partition, const QColor& color ) +{ + m_device = device; + m_originalPartition = partition; + // PartResizerWidget stores its changes directly in the partition it is + // initialized with. We don't want the changes to be committed that way, + // because it means we would have to revert them if the user cancel the + // dialog the widget is in. Therefore we init PartResizerWidget with a clone + // of the original partition. + m_partition.reset( KPMHelpers::clonePartition( m_device, partition ) ); + m_partitionColor = color; +} + +void +PartitionSizeController::setPartResizerWidget( PartResizerWidget* widget, bool format ) +{ + Q_ASSERT( m_device ); + + if ( m_partResizerWidget ) + { + disconnect( m_partResizerWidget, nullptr, this, nullptr ); + } + + m_dirty = false; + m_currentSpinBoxValue = -1; + + // Update partition filesystem. This must be done *before* the call to + // PartResizerWidget::init() otherwise it will be ignored by the widget. + // This is why this method accept a `format` boolean. + qint64 used = format ? 0 : m_originalPartition->fileSystem().sectorsUsed(); + m_partition->fileSystem().setSectorsUsed( used ); + + // Init PartResizerWidget + m_partResizerWidget = widget; + PartitionTable* table = m_device->partitionTable(); + qint64 minFirstSector = m_originalPartition->firstSector() - table->freeSectorsBefore( *m_originalPartition ); + qint64 maxLastSector = m_originalPartition->lastSector() + table->freeSectorsAfter( *m_originalPartition ); + m_partResizerWidget->init( *m_device, *m_partition.data(), minFirstSector, maxLastSector ); + + // FIXME: Should be set by PartResizerWidget itself + m_partResizerWidget->setFixedHeight( PartResizerWidget::handleHeight() ); + + QPalette pal = widget->palette(); + pal.setColor( QPalette::Base, ColorUtils::freeSpaceColor() ); + pal.setColor( QPalette::Button, m_partitionColor ); + m_partResizerWidget->setPalette( pal ); + connectWidgets(); + + if ( !format ) + { + // If we are not formatting, update the widget to make sure the space + // between the first and last sectors is big enough to fit the existing + // content. + m_updating = true; + + qint64 firstSector = m_partition->firstSector(); + qint64 lastSector = m_partition->lastSector(); + + // This first time we call doAAUPRW with real first/last sector, + // all further calls will come from updatePartResizerWidget, and + // will therefore use values calculated from the SpinBox. + doAlignAndUpdatePartResizerWidget( firstSector, lastSector ); + + m_updating = false; + } +} + +void +PartitionSizeController::setSpinBox( QSpinBox* spinBox ) +{ + if ( m_spinBox ) + { + disconnect( m_spinBox, nullptr, this, nullptr ); + } + m_spinBox = spinBox; + m_spinBox->setMaximum( std::numeric_limits< int >::max() ); + connectWidgets(); +} + +void +PartitionSizeController::connectWidgets() +{ + if ( !m_spinBox || !m_partResizerWidget ) + { + return; + } + + connect( m_spinBox, SIGNAL( editingFinished() ), SLOT( updatePartResizerWidget() ) ); + connect( m_partResizerWidget, SIGNAL( firstSectorChanged( qint64 ) ), SLOT( updateSpinBox() ) ); + connect( m_partResizerWidget, SIGNAL( lastSectorChanged( qint64 ) ), SLOT( updateSpinBox() ) ); + + // Init m_spinBox from m_partResizerWidget + updateSpinBox(); +} + +void +PartitionSizeController::updatePartResizerWidget() +{ + if ( m_updating ) + { + return; + } + if ( m_spinBox->value() == m_currentSpinBoxValue ) + { + return; + } + + m_updating = true; + qint64 sectorSize = qint64( m_spinBox->value() ) * 1024 * 1024 / m_device->logicalSize(); + + qint64 firstSector = m_partition->firstSector(); + qint64 lastSector = firstSector + sectorSize - 1; + + doAlignAndUpdatePartResizerWidget( firstSector, lastSector ); + + m_updating = false; +} + +void +PartitionSizeController::doAlignAndUpdatePartResizerWidget( qint64 firstSector, qint64 lastSector ) +{ + if ( lastSector > m_partResizerWidget->maximumLastSector() ) + { + qint64 delta = lastSector - m_partResizerWidget->maximumLastSector(); + firstSector -= delta; + lastSector -= delta; + } + if ( lastSector != m_partition->lastSector() ) + { + m_partResizerWidget->updateLastSector( lastSector ); + m_dirty = true; + } + if ( firstSector != m_partition->firstSector() ) + { + m_partResizerWidget->updateFirstSector( firstSector ); + m_dirty = true; + } + + // Update spinbox value in case it was an impossible value + doUpdateSpinBox(); +} + +void +PartitionSizeController::updateSpinBox() +{ + if ( m_updating ) + { + return; + } + m_updating = true; + doUpdateSpinBox(); + m_updating = false; +} + +void +PartitionSizeController::doUpdateSpinBox() +{ + if ( !m_spinBox ) + { + return; + } + int mbSize = Calamares::BytesToMiB( m_partition->length() * m_device->logicalSize() ); + m_spinBox->setValue( mbSize ); + if ( m_currentSpinBoxValue != -1 && //if it's not the first time we're setting it + m_currentSpinBoxValue != mbSize ) //and the operation changes the SB value + { + m_dirty = true; + } + m_currentSpinBoxValue = mbSize; +} + +qint64 +PartitionSizeController::firstSector() const +{ + return m_partition->firstSector(); +} + +qint64 +PartitionSizeController::lastSector() const +{ + return m_partition->lastSector(); +} + +bool +PartitionSizeController::isDirty() const +{ + return m_dirty; +} diff --git a/calamares/src/modules/partition/gui/PartitionSizeController.h b/calamares/src/modules/partition/gui/PartitionSizeController.h new file mode 100644 index 0000000..69cf2ef --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionSizeController.h @@ -0,0 +1,72 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONSIZECONTROLLER_H +#define PARTITIONSIZECONTROLLER_H + +// KPMcore +#include + +// Qt +#include +#include +#include +#include + +class QSpinBox; + +class Device; +class Partition; +class PartResizerWidget; + +/** + * Synchronizes a PartResizerWidget and a QSpinBox, making sure any change made + * to one is reflected in the other. + * + * It does not touch the partition it works on: changes are exposed through the + * firstSector() and lastSector() getters. + */ +class PartitionSizeController : public QObject +{ + Q_OBJECT +public: + explicit PartitionSizeController( QObject* parent = nullptr ); + void init( Device* device, Partition* partition, const QColor& color ); + void setPartResizerWidget( PartResizerWidget* widget, bool format = true ); + void setSpinBox( QSpinBox* spinBox ); + + qint64 firstSector() const; + qint64 lastSector() const; + + bool isDirty() const; + +private: + QPointer< PartResizerWidget > m_partResizerWidget; + QPointer< QSpinBox > m_spinBox; + Device* m_device = nullptr; + const Partition* m_originalPartition = nullptr; + QScopedPointer< Partition > m_partition; + QColor m_partitionColor; + + bool m_updating = false; + + void connectWidgets(); + void doUpdateSpinBox(); + void doAlignAndUpdatePartResizerWidget( qint64 fistSector, qint64 lastSector ); + + bool m_dirty = false; + qint64 m_currentSpinBoxValue = -1; + +private Q_SLOTS: + void updatePartResizerWidget(); + void updateSpinBox(); +}; + +#endif /* PARTITIONSIZECONTROLLER_H */ diff --git a/calamares/src/modules/partition/gui/PartitionSplitterWidget.cpp b/calamares/src/modules/partition/gui/PartitionSplitterWidget.cpp new file mode 100644 index 0000000..9fef8e3 --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionSplitterWidget.cpp @@ -0,0 +1,636 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionSplitterWidget.h" + +#include "core/ColorUtils.h" +#include "core/KPMHelpers.h" + +#include "partition/PartitionIterator.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" + +#include "utils/Gui.h" + +#include +#include + +#include +#include +#include +#include +#include + +using Calamares::Partition::PartitionIterator; + +static const int VIEW_HEIGHT = qMax( Calamares::defaultFontHeight() + 8, // wins out with big fonts + int( Calamares::defaultFontHeight() * 0.6 ) + 22 ); // wins out with small fonts +static const int CORNER_RADIUS = 3; +static const int EXTENDED_PARTITION_MARGIN = qMax( 4, VIEW_HEIGHT / 6 ); + +/** @brief Applies @p operation to each item + * + * A PartitionSplitterItem can contain a tree of items (each item has its + * own list of children) so recurse over all the children. Returns a count + * of how many items were affected. + */ +static int +countTransform( QVector< PartitionSplitterItem >& items, + const std::function< bool( PartitionSplitterItem& ) >& operation ) +{ + int opCount = 0; + for ( auto it = items.begin(); it != items.end(); ++it ) + { + if ( operation( *it ) ) + { + opCount++; + } + + opCount += countTransform( it->children, operation ); + } + return opCount; +} + +static PartitionSplitterItem +findTransform( QVector< PartitionSplitterItem >& items, std::function< bool( PartitionSplitterItem& ) > condition ) +{ + for ( auto it = items.begin(); it != items.end(); ++it ) + { + if ( condition( *it ) ) + { + return *it; + } + + PartitionSplitterItem candidate = findTransform( it->children, condition ); + if ( !candidate.isNull() ) + { + return candidate; + } + } + return PartitionSplitterItem::null(); +} + +PartitionSplitterWidget::PartitionSplitterWidget( QWidget* parent ) + : QWidget( parent ) + , m_itemToResize( PartitionSplitterItem::null() ) + , m_itemToResizeNext( PartitionSplitterItem::null() ) + , m_itemMinSize( 0 ) + , m_itemMaxSize( 0 ) + , m_itemPrefSize( 0 ) + , m_resizing( false ) + , m_resizeHandleX( 0 ) + , HANDLE_SNAP( QApplication::startDragDistance() ) + , m_drawNestedPartitions( false ) +{ + setMouseTracking( true ); +} + +void +PartitionSplitterWidget::init( Device* dev, bool drawNestedPartitions ) +{ + m_drawNestedPartitions = drawNestedPartitions; + QVector< PartitionSplitterItem > allPartitionItems; + PartitionSplitterItem* extendedPartitionItem = nullptr; + for ( auto it = PartitionIterator::begin( dev ); it != PartitionIterator::end( dev ); ++it ) + { + PartitionSplitterItem newItem = { ( *it )->partitionPath(), + ColorUtils::colorForPartition( *it ), + Calamares::Partition::isPartitionFreeSpace( *it ), + ( *it )->capacity(), + PartitionSplitterItem::Normal, + {} }; + + // If we don't draw child partitions of a partitions as child partitions, we + // need to flatten the items tree into an items list + if ( drawNestedPartitions ) + { + if ( ( *it )->roles().has( PartitionRole::Logical ) && extendedPartitionItem ) + { + extendedPartitionItem->children.append( newItem ); + } + else + { + allPartitionItems.append( newItem ); + if ( ( *it )->roles().has( PartitionRole::Extended ) ) + { + extendedPartitionItem = &allPartitionItems.last(); + } + } + } + else + { + if ( !( *it )->roles().has( PartitionRole::Extended ) ) + { + allPartitionItems.append( newItem ); + } + } + } + + setupItems( allPartitionItems ); +} + +void +PartitionSplitterWidget::setupItems( const QVector< PartitionSplitterItem >& items ) +{ + m_itemToResize = PartitionSplitterItem::null(); + m_itemToResizeNext = PartitionSplitterItem::null(); + m_itemToResizePath.clear(); + + m_items.clear(); + m_items = items; + repaint(); + for ( const PartitionSplitterItem& item : items ) + { + cDebug() << "PSI added item" << item.itemPath << "size" << item.size; + } +} + +void +PartitionSplitterWidget::setSplitPartition( const QString& path, qint64 minSize, qint64 maxSize, qint64 preferredSize ) +{ + cDebug() << "path:" << path << Logger::Continuation << "minSize:" << minSize << Logger::Continuation + << "maxSize:" << maxSize << Logger::Continuation << "prfSize:" << preferredSize; + + if ( m_itemToResize && m_itemToResizeNext ) + { + cDebug() << "NOTICE: trying to split partition but partition to split is already set."; + + // We need to remove the itemToResizeNext from wherever it is + for ( int i = 0; i < m_items.count(); ++i ) + { + if ( m_items[ i ].itemPath == m_itemToResize.itemPath + && m_items[ i ].status == PartitionSplitterItem::Resizing && i + 1 < m_items.count() ) + { + m_items[ i ].size = m_items[ i ].size + m_itemToResizeNext.size; + m_items[ i ].status = PartitionSplitterItem::Normal; + m_items.removeAt( i + 1 ); + m_itemToResizeNext = PartitionSplitterItem::null(); + break; + } + else if ( !m_items[ i ].children.isEmpty() ) + { + for ( int j = 0; j < m_items[ i ].children.count(); ++j ) + { + if ( m_items[ i ].children[ j ].itemPath == m_itemToResize.itemPath + && j + 1 < m_items[ i ].children.count() ) + { + m_items[ i ].children[ j ].size = m_items[ i ].children[ j ].size + m_itemToResizeNext.size; + m_items[ i ].children[ j ].status = PartitionSplitterItem::Normal; + m_items[ i ].children.removeAt( j + 1 ); + m_itemToResizeNext = PartitionSplitterItem::null(); + break; + } + } + if ( m_itemToResizeNext.isNull() ) + { + break; + } + } + } + + m_itemToResize = PartitionSplitterItem::null(); + m_itemToResizePath.clear(); + } + + PartitionSplitterItem itemToResize = findTransform( m_items, + [ path ]( PartitionSplitterItem& item ) -> bool + { + if ( path == item.itemPath ) + { + item.status = PartitionSplitterItem::Resizing; + return true; + } + return false; + } ); + + if ( itemToResize.isNull() ) + { + return; + } + cDebug() << "itemToResize:" << itemToResize.itemPath; + + m_itemToResize = itemToResize; + m_itemToResizePath = path; + + if ( preferredSize > maxSize ) + { + preferredSize = maxSize; + } + + qint64 newSize = m_itemToResize.size - preferredSize; + m_itemToResize.size = preferredSize; + int opCount = countTransform( m_items, + [ preferredSize ]( PartitionSplitterItem& item ) -> bool + { + if ( item.status == PartitionSplitterItem::Resizing ) + { + item.size = preferredSize; + return true; + } + return false; + } ); + cDebug() << "each splitter item opcount:" << opCount; + m_itemMinSize = minSize; + m_itemMaxSize = maxSize; + m_itemPrefSize = preferredSize; + + for ( int i = 0; i < m_items.count(); ++i ) + { + if ( m_items[ i ].itemPath == itemToResize.itemPath ) + { + m_items.insert( i + 1, + { "", QColor( "#c0392b" ), false, newSize, PartitionSplitterItem::ResizingNext, {} } ); + m_itemToResizeNext = m_items[ i + 1 ]; + break; + } + else if ( !m_items[ i ].children.isEmpty() ) + { + for ( int j = 0; j < m_items[ i ].children.count(); ++j ) + { + if ( m_items[ i ].children[ j ].itemPath == itemToResize.itemPath ) + { + m_items[ i ].children.insert( + j + 1, { "", QColor( "#c0392b" ), false, newSize, PartitionSplitterItem::ResizingNext, {} } ); + m_itemToResizeNext = m_items[ i ].children[ j + 1 ]; + break; + } + } + if ( !m_itemToResizeNext.isNull() ) + { + break; + } + } + } + + Q_EMIT partitionResized( m_itemToResize.itemPath, m_itemToResize.size, m_itemToResizeNext.size ); + + cDebug() << "Items updated. Status:"; + foreach ( const PartitionSplitterItem& item, m_items ) + { + cDebug() << "item" << item.itemPath << "size" << item.size << "status:" << item.status; + } + + cDebug() << "m_itemToResize: " << !m_itemToResize.isNull() << m_itemToResize.itemPath; + cDebug() << "m_itemToResizeNext:" << !m_itemToResizeNext.isNull() << m_itemToResizeNext.itemPath; + + repaint(); +} + +qint64 +PartitionSplitterWidget::splitPartitionSize() const +{ + if ( !m_itemToResize ) + { + return -1; + } + return m_itemToResize.size; +} + +qint64 +PartitionSplitterWidget::newPartitionSize() const +{ + if ( !m_itemToResizeNext ) + { + return -1; + } + return m_itemToResizeNext.size; +} + +QSize +PartitionSplitterWidget::sizeHint() const +{ + return QSize( -1, VIEW_HEIGHT ); +} + +QSize +PartitionSplitterWidget::minimumSizeHint() const +{ + return sizeHint(); +} + +void +PartitionSplitterWidget::paintEvent( QPaintEvent* event ) +{ + Q_UNUSED( event ) + + QPainter painter( this ); + painter.fillRect( rect(), palette().window() ); + painter.setRenderHint( QPainter::Antialiasing ); + + drawPartitions( &painter, rect(), m_items ); +} + +void +PartitionSplitterWidget::mousePressEvent( QMouseEvent* event ) +{ + if ( m_itemToResize && m_itemToResizeNext && event->button() == Qt::LeftButton ) + { +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + if ( qAbs( event->x() - m_resizeHandleX ) < HANDLE_SNAP ) +#else + if ( qAbs( event->position().x() - m_resizeHandleX ) < HANDLE_SNAP ) +#endif + { + m_resizing = true; + } + } +} + +void +PartitionSplitterWidget::mouseMoveEvent( QMouseEvent* event ) +{ + if ( m_resizing ) + { + qint64 start = 0; + QString itemPath = m_itemToResize.itemPath; + for ( auto it = m_items.constBegin(); it != m_items.constEnd(); ++it ) + { + if ( it->itemPath == itemPath ) + { + break; + } + else if ( !it->children.isEmpty() ) + { + bool done = false; + for ( auto jt = it->children.constBegin(); jt != it->children.constEnd(); ++jt ) + { + if ( jt->itemPath == itemPath ) + { + done = true; + break; + } + start += jt->size; + } + if ( done ) + { + break; + } + } + else + { + start += it->size; + } + } + + qint64 total = 0; + for ( auto it = m_items.constBegin(); it != m_items.constEnd(); ++it ) + { + total += it->size; + } + + int ew = rect().width(); //effective width + qreal bpp = total / static_cast< qreal >( ew ); //bytes per pixel + +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + qreal mx = event->x() * bpp - start; +#else + qreal mx = event->position().x() * bpp - start; +#endif + + // make sure we are within resize range + mx = qBound( static_cast< qreal >( m_itemMinSize ), mx, static_cast< qreal >( m_itemMaxSize ) ); + + qint64 span = m_itemPrefSize; + qreal percent = mx / span; + qint64 oldsize = m_itemToResize.size; + + m_itemToResize.size = qRound64( span * percent ); + m_itemToResizeNext.size -= m_itemToResize.size - oldsize; + countTransform( m_items, + [ this ]( PartitionSplitterItem& item ) -> bool + { + if ( item.status == PartitionSplitterItem::Resizing ) + { + item.size = m_itemToResize.size; + return true; + } + else if ( item.status == PartitionSplitterItem::ResizingNext ) + { + item.size = m_itemToResizeNext.size; + return true; + } + return false; + } ); + + repaint(); + + Q_EMIT partitionResized( itemPath, m_itemToResize.size, m_itemToResizeNext.size ); + } + else + { + if ( m_itemToResize && m_itemToResizeNext ) + { +#if QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) + if ( qAbs( event->x() - m_resizeHandleX ) < HANDLE_SNAP ) +#else + if ( qAbs( event->position().x() - m_resizeHandleX ) < HANDLE_SNAP ) +#endif + { + setCursor( Qt::SplitHCursor ); + } + else if ( cursor().shape() != Qt::ArrowCursor ) + { + setCursor( Qt::ArrowCursor ); + } + } + } +} + +void +PartitionSplitterWidget::mouseReleaseEvent( QMouseEvent* event ) +{ + Q_UNUSED( event ) + + m_resizing = false; +} + +void +PartitionSplitterWidget::drawSection( QPainter* painter, + const QRect& rect_, + int x, + int width, + const PartitionSplitterItem& item ) +{ + QColor color = item.color; + bool isFreeSpace = item.isFreeSpace; + + QRect rect = rect_; + const int y = rect.y(); + const int rectHeight = rect.height(); + const int radius = qMax( 1, CORNER_RADIUS - ( height() - rectHeight ) / 2 ); + painter->setClipRect( x, y, width, rectHeight ); + painter->translate( 0.5, 0.5 ); + + rect.adjust( 0, 0, -1, -1 ); + const QColor borderColor = color.darker(); + painter->setPen( borderColor ); + painter->setBrush( color ); + painter->drawRoundedRect( rect, radius, radius ); + + // Draw shade + if ( !isFreeSpace ) + { + rect.adjust( 2, 2, -2, -2 ); + } + + QLinearGradient gradient( 0, 0, 0, rectHeight / 2 ); + + qreal c = isFreeSpace ? 0 : 1; + gradient.setColorAt( 0, QColor::fromRgbF( c, c, c, 0.3 ) ); + gradient.setColorAt( 1, QColor::fromRgbF( c, c, c, 0 ) ); + + painter->setPen( Qt::NoPen ); + painter->setBrush( gradient ); + painter->drawRoundedRect( rect, radius, radius ); + + painter->translate( -0.5, -0.5 ); +} + +void +PartitionSplitterWidget::drawResizeHandle( QPainter* painter, const QRect& rect_, int x ) +{ + if ( !m_itemToResize ) + { + return; + } + + painter->setPen( Qt::NoPen ); + painter->setBrush( Qt::black ); + painter->setClipRect( rect_ ); + + painter->setRenderHint( QPainter::Antialiasing, true ); + + qreal h = VIEW_HEIGHT; // Put the arrow in the center regardless of inner box height + int scaleFactor = qRound( height() / static_cast< qreal >( VIEW_HEIGHT ) ); + QList< QPair< qreal, qreal > > arrow_offsets + = { qMakePair( 0, h / 2 - 1 ), qMakePair( 4, h / 2 - 1 ), qMakePair( 4, h / 2 - 3 ), qMakePair( 8, h / 2 ), + qMakePair( 4, h / 2 + 3 ), qMakePair( 4, h / 2 + 1 ), qMakePair( 0, h / 2 + 1 ) }; + for ( int i = 0; i < arrow_offsets.count(); ++i ) + { + arrow_offsets[ i ] = qMakePair( arrow_offsets[ i ].first * scaleFactor, + ( arrow_offsets[ i ].second - h / 2 ) * scaleFactor + h / 2 ); + } + + auto p1 = arrow_offsets[ 0 ]; + if ( m_itemToResize.size > m_itemMinSize ) + { + auto arrow = QPainterPath( QPointF( x + -1 * p1.first, p1.second ) ); + for ( auto p : arrow_offsets ) + { + arrow.lineTo( x + -1 * p.first + 1, p.second ); + } + painter->drawPath( arrow ); + } + + if ( m_itemToResize.size < m_itemMaxSize ) + { + auto arrow = QPainterPath( QPointF( x + p1.first, p1.second ) ); + for ( auto p : arrow_offsets ) + { + arrow.lineTo( x + p.first, p.second ); + } + painter->drawPath( arrow ); + } + + painter->setRenderHint( QPainter::Antialiasing, false ); + painter->setPen( Qt::black ); + painter->drawLine( x, 0, x, int( h ) - 1 ); +} + +void +PartitionSplitterWidget::drawPartitions( QPainter* painter, + const QRect& rect, + const QVector< PartitionSplitterItem >& itemList ) +{ + const int count = itemList.count(); + const int totalWidth = rect.width(); + + auto pair = computeItemsVector( itemList ); + QVector< PartitionSplitterItem >& items = pair.first; + qreal total = pair.second; + + int x = rect.x(); + for ( int row = 0; row < count; ++row ) + { + const PartitionSplitterItem& item = items[ row ]; + qreal width; + if ( row < count - 1 ) + { + width = totalWidth * ( item.size / total ); + } + else + // Make sure we fill the last pixel column + { + width = rect.right() - x + 1; + } + + drawSection( painter, rect, x, int( width ), item ); + if ( !item.children.isEmpty() ) + { + QRect subRect( x + EXTENDED_PARTITION_MARGIN, + rect.y() + EXTENDED_PARTITION_MARGIN, + int( width ) - 2 * EXTENDED_PARTITION_MARGIN, + rect.height() - 2 * EXTENDED_PARTITION_MARGIN ); + drawPartitions( painter, subRect, item.children ); + } + + // If an item to resize and the following new item both exist, + // and this is not the very first partition, + // and the partition preceding this one is the item to resize... + if ( m_itemToResize && m_itemToResizeNext && row > 0 && !items[ row - 1 ].isFreeSpace + && !items[ row - 1 ].itemPath.isEmpty() && items[ row - 1 ].itemPath == m_itemToResize.itemPath ) + { + m_resizeHandleX = x; + drawResizeHandle( painter, rect, m_resizeHandleX ); + } + + x += width; + } +} + +QPair< QVector< PartitionSplitterItem >, qreal > +PartitionSplitterWidget::computeItemsVector( const QVector< PartitionSplitterItem >& originalItems ) const +{ + QVector< PartitionSplitterItem > items; + + qreal total = 0; + for ( int row = 0; row < originalItems.count(); ++row ) + { + if ( originalItems[ row ].children.isEmpty() ) + { + items += originalItems[ row ]; + total += originalItems[ row ].size; + } + else + { + PartitionSplitterItem thisItem = originalItems[ row ]; + QPair< QVector< PartitionSplitterItem >, qreal > pair = computeItemsVector( thisItem.children ); + thisItem.children = pair.first; + thisItem.size = qint64( pair.second ); + items += thisItem; + total += thisItem.size; + } + } + + // The sizes we have are perfect, but now we have to hardcode a minimum size for small + // partitions and compensate for it in the total. + qreal adjustedTotal = total; + for ( int row = 0; row < items.count(); ++row ) + { + if ( items[ row ].size < 0.01 * total ) // If this item is smaller than 1% of everything, + { + // force its width to 1%. + adjustedTotal -= items[ row ].size; + items[ row ].size = qint64( 0.01 * total ); + adjustedTotal += items[ row ].size; + } + } + + return qMakePair( items, adjustedTotal ); +} diff --git a/calamares/src/modules/partition/gui/PartitionSplitterWidget.h b/calamares/src/modules/partition/gui/PartitionSplitterWidget.h new file mode 100644 index 0000000..53f5b0b --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionSplitterWidget.h @@ -0,0 +1,94 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONSPLITTERWIDGET_H +#define PARTITIONSPLITTERWIDGET_H + +#include + +#include + +class Device; + +struct PartitionSplitterItem +{ + enum Status + { + Normal = 0, + Resizing, + ResizingNext + }; + + QString itemPath; + QColor color; + bool isFreeSpace; + qint64 size; + Status status; + + using ChildVector = QVector< PartitionSplitterItem >; + ChildVector children; + + static PartitionSplitterItem null() { return { QString(), QColor(), false, 0, Normal, ChildVector() }; } + + bool isNull() const { return itemPath.isEmpty() && size == 0 && status == Normal; } + operator bool() const { return !isNull(); } +}; + +class PartitionSplitterWidget : public QWidget +{ + Q_OBJECT +public: + explicit PartitionSplitterWidget( QWidget* parent = nullptr ); + + void init( Device* dev, bool drawNestedPartitions ); + + void setSplitPartition( const QString& path, qint64 minSize, qint64 maxSize, qint64 preferredSize ); + + qint64 splitPartitionSize() const; + qint64 newPartitionSize() const; + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +signals: + void partitionResized( const QString&, qint64, qint64 ); + +protected: + void paintEvent( QPaintEvent* event ) override; + void mousePressEvent( QMouseEvent* event ) override; + void mouseMoveEvent( QMouseEvent* event ) override; + void mouseReleaseEvent( QMouseEvent* event ) override; + +private: + void setupItems( const QVector< PartitionSplitterItem >& items ); + + void drawPartitions( QPainter* painter, const QRect& rect, const QVector< PartitionSplitterItem >& itemList ); + void drawSection( QPainter* painter, const QRect& rect_, int x, int width, const PartitionSplitterItem& item ); + void drawResizeHandle( QPainter* painter, const QRect& rect_, int x ); + + QPair< QVector< PartitionSplitterItem >, qreal > + computeItemsVector( const QVector< PartitionSplitterItem >& originalItems ) const; + + QVector< PartitionSplitterItem > m_items; + QString m_itemToResizePath; + PartitionSplitterItem m_itemToResize; + PartitionSplitterItem m_itemToResizeNext; + + qint64 m_itemMinSize; + qint64 m_itemMaxSize; + qint64 m_itemPrefSize; + bool m_resizing; + int m_resizeHandleX; + + const int HANDLE_SNAP; + + bool m_drawNestedPartitions; +}; + +#endif // PARTITIONSPLITTERWIDGET_H diff --git a/calamares/src/modules/partition/gui/PartitionViewSelectionFilter.h b/calamares/src/modules/partition/gui/PartitionViewSelectionFilter.h new file mode 100644 index 0000000..fc2f5bc --- /dev/null +++ b/calamares/src/modules/partition/gui/PartitionViewSelectionFilter.h @@ -0,0 +1,19 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONVIEWSELECTIONFILTER_H +#define PARTITIONVIEWSELECTIONFILTER_H + +#include + +#include + +typedef std::function< bool( const QModelIndex& ) > SelectionFilter; + +#endif // PARTITIONVIEWSELECTIONFILTER_H diff --git a/calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.cpp b/calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.cpp new file mode 100644 index 0000000..d0d7e7e --- /dev/null +++ b/calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.cpp @@ -0,0 +1,59 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ResizeVolumeGroupDialog.h" + +#include "gui/ListPhysicalVolumeWidgetItem.h" + +#include +#include + +#include +#include +#include +#include + +ResizeVolumeGroupDialog::ResizeVolumeGroupDialog( LvmDevice* device, + const PartitionVector& availablePVs, + PartitionVector& selectedPVs, + QWidget* parent ) + : VolumeGroupBaseDialog( device->name(), device->physicalVolumes(), parent ) + , m_selectedPVs( selectedPVs ) +{ + setWindowTitle( tr( "Resize Volume Group", "@title" ) ); + + for ( int i = 0; i < pvList()->count(); i++ ) + { + pvList()->item( i )->setCheckState( Qt::Checked ); + } + + for ( const Partition* p : availablePVs ) + { + pvList()->addItem( new ListPhysicalVolumeWidgetItem( p, false ) ); + } + + peSize()->setValue( + static_cast< int >( device->peSize() / Capacity::unitFactor( Capacity::Unit::Byte, Capacity::Unit::MiB ) ) ); + + vgName()->setEnabled( false ); + peSize()->setEnabled( false ); + vgType()->setEnabled( false ); + + setUsedSizeValue( device->allocatedPE() * device->peSize() ); + setLVQuantity( device->partitionTable()->children().count() ); +} + +void +ResizeVolumeGroupDialog::accept() +{ + m_selectedPVs << checkedItems(); + + QDialog::accept(); +} diff --git a/calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.h b/calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.h new file mode 100644 index 0000000..7b8ecf6 --- /dev/null +++ b/calamares/src/modules/partition/gui/ResizeVolumeGroupDialog.h @@ -0,0 +1,35 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef RESIZEVOLUMEGROUPDIALOG_H +#define RESIZEVOLUMEGROUPDIALOG_H + +#include "gui/VolumeGroupBaseDialog.h" + +class LvmDevice; + +class ResizeVolumeGroupDialog : public VolumeGroupBaseDialog +{ + Q_OBJECT +public: + using PartitionVector = QVector< const Partition* >; + + ResizeVolumeGroupDialog( LvmDevice* device, + const PartitionVector& availablePVs, + PartitionVector& selectedPVs, + QWidget* parent ); + + void accept() override; + +private: + PartitionVector& m_selectedPVs; +}; + +#endif // RESIZEVOLUMEGROUPDIALOG_H diff --git a/calamares/src/modules/partition/gui/ScanningDialog.cpp b/calamares/src/modules/partition/gui/ScanningDialog.cpp new file mode 100644 index 0000000..ce8de22 --- /dev/null +++ b/calamares/src/modules/partition/gui/ScanningDialog.cpp @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ScanningDialog.h" + +#include "widgets/waitingspinnerwidget.h" + +#include +#include +#include +#include + + +ScanningDialog::ScanningDialog( const QString& text, const QString& windowTitle, QWidget* parent ) + : QDialog( parent ) +{ + setModal( true ); + setWindowTitle( windowTitle ); + + QHBoxLayout* dialogLayout = new QHBoxLayout; + setLayout( dialogLayout ); + + WaitingSpinnerWidget* spinner = new WaitingSpinnerWidget(); + dialogLayout->addWidget( spinner ); + spinner->start(); + + QLabel* rescanningLabel = new QLabel( text, this ); + dialogLayout->addWidget( rescanningLabel ); +} + + +void +ScanningDialog::run( const QFuture< void >& future, + const QString& text, + const QString& windowTitle, + const std::function< void() >& callback, + QWidget* parent ) +{ + ScanningDialog* theDialog = new ScanningDialog( text, windowTitle, parent ); + theDialog->show(); + + QFutureWatcher< void >* watcher = new QFutureWatcher< void >(); + connect( watcher, + &QFutureWatcher< void >::finished, + theDialog, + [ watcher, theDialog, callback ] + { + watcher->deleteLater(); + theDialog->hide(); + theDialog->deleteLater(); + callback(); + } ); + + watcher->setFuture( future ); +} + + +void +ScanningDialog::run( const QFuture< void >& future, const std::function< void() >& callback, QWidget* parent ) +{ + ScanningDialog::run( + future, tr( "Scanning storage devices…", "@status" ), tr( "Partitioning…", "@status" ), callback, parent ); +} + +void +ScanningDialog::setVisible( bool visible ) +{ + QDialog::setVisible( visible ); + Q_EMIT visibilityChanged(); +} diff --git a/calamares/src/modules/partition/gui/ScanningDialog.h b/calamares/src/modules/partition/gui/ScanningDialog.h new file mode 100644 index 0000000..757b94e --- /dev/null +++ b/calamares/src/modules/partition/gui/ScanningDialog.h @@ -0,0 +1,43 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SCANNINGDIALOG_H +#define SCANNINGDIALOG_H + +#include +#include + +#include + +class ScanningDialog : public QDialog +{ + Q_OBJECT +public: + explicit ScanningDialog( const QString& text, const QString& windowTitle, QWidget* parent = nullptr ); + + static void run( + const QFuture< void >& future, + const QString& text, + const QString& windowTitle, + const std::function< void() >& callback = [] {}, + QWidget* parent = nullptr ); + + static void run( + const QFuture< void >& future, + const std::function< void() >& callback = [] {}, + QWidget* parent = nullptr ); + +public slots: + void setVisible( bool visible ) override; + +signals: + void visibilityChanged(); +}; + +#endif // SCANNINGDIALOG_H diff --git a/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.cpp b/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.cpp new file mode 100644 index 0000000..818a604 --- /dev/null +++ b/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.cpp @@ -0,0 +1,185 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "VolumeGroupBaseDialog.h" +#include "ui_VolumeGroupBaseDialog.h" + +#include "core/SizeUtils.h" +#include "gui/ListPhysicalVolumeWidgetItem.h" + +#include +#include +#include +#include +#include +#include + +VolumeGroupBaseDialog::VolumeGroupBaseDialog( QString& vgName, QVector< const Partition* > pvList, QWidget* parent ) + : QDialog( parent ) + , ui( new Ui::VolumeGroupBaseDialog ) + , m_vgNameValue( vgName ) + , m_totalSizeValue( 0 ) + , m_usedSizeValue( 0 ) +{ + ui->setupUi( this ); + + for ( const Partition* p : pvList ) + { + ui->pvList->addItem( new ListPhysicalVolumeWidgetItem( p, false ) ); + } + + ui->vgType->addItems( QStringList() << "LVM" + << "RAID" ); + ui->vgType->setCurrentIndex( 0 ); + + QRegularExpression re( R"(^(?!_|\.)[\w\-.+]+)" ); + ui->vgName->setValidator( new QRegularExpressionValidator( re, this ) ); + ui->vgName->setText( m_vgNameValue ); + + updateOkButton(); + updateTotalSize(); + + connect( ui->pvList, + &QListWidget::itemChanged, + this, + [ & ]( QListWidgetItem* ) + { + updateTotalSize(); + updateOkButton(); + } ); + + connect( ui->peSize, + qOverload< int >( &QSpinBox::valueChanged ), + this, + [ & ]( int ) + { + updateTotalSectors(); + updateOkButton(); + } ); + + connect( ui->vgName, &QLineEdit::textChanged, this, [ & ]( const QString& ) { updateOkButton(); } ); +} + +VolumeGroupBaseDialog::~VolumeGroupBaseDialog() +{ + delete ui; +} + +QVector< const Partition* > +VolumeGroupBaseDialog::checkedItems() const +{ + QVector< const Partition* > items; + + for ( int i = 0; i < ui->pvList->count(); i++ ) + { + ListPhysicalVolumeWidgetItem* item = dynamic_cast< ListPhysicalVolumeWidgetItem* >( ui->pvList->item( i ) ); + + if ( item && item->checkState() == Qt::Checked ) + { + items << item->partition(); + } + } + + return items; +} + +bool +VolumeGroupBaseDialog::isSizeValid() const +{ + return m_totalSizeValue >= m_usedSizeValue; +} + +void +VolumeGroupBaseDialog::updateOkButton() +{ + okButton()->setEnabled( isSizeValid() && !checkedItems().empty() && !ui->vgName->text().isEmpty() + && ui->peSize->value() > 0 ); +} + +void +VolumeGroupBaseDialog::setUsedSizeValue( qint64 usedSize ) +{ + m_usedSizeValue = usedSize; + + ui->usedSize->setText( formatByteSize( m_usedSizeValue ) ); +} + +void +VolumeGroupBaseDialog::setLVQuantity( qint32 lvQuantity ) +{ + ui->lvQuantity->setText( QString::number( lvQuantity ) ); +} + +void +VolumeGroupBaseDialog::updateTotalSize() +{ + m_totalSizeValue = 0; + + for ( const Partition* p : checkedItems() ) + { + m_totalSizeValue += p->capacity() + - p->capacity() + % ( ui->peSize->value() * Capacity::unitFactor( Capacity::Unit::Byte, Capacity::Unit::MiB ) ); + } + + ui->totalSize->setText( formatByteSize( m_totalSizeValue ) ); + + updateTotalSectors(); +} + +void +VolumeGroupBaseDialog::updateTotalSectors() +{ + qint64 totalSectors = 0; + + qint64 extentSize = ui->peSize->value() * Capacity::unitFactor( Capacity::Unit::Byte, Capacity::Unit::MiB ); + + if ( extentSize > 0 ) + { + totalSectors = m_totalSizeValue / extentSize; + } + + ui->totalSectors->setText( QString::number( totalSectors ) ); +} + +QString& +VolumeGroupBaseDialog::vgNameValue() const +{ + return m_vgNameValue; +} + +QLineEdit* +VolumeGroupBaseDialog::vgName() const +{ + return ui->vgName; +} + +QComboBox* +VolumeGroupBaseDialog::vgType() const +{ + return ui->vgType; +} + +QSpinBox* +VolumeGroupBaseDialog::peSize() const +{ + return ui->peSize; +} + +QListWidget* +VolumeGroupBaseDialog::pvList() const +{ + return ui->pvList; +} + +QPushButton* +VolumeGroupBaseDialog::okButton() const +{ + return ui->buttonBox->button( QDialogButtonBox::StandardButton::Ok ); +} diff --git a/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.h b/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.h new file mode 100644 index 0000000..56379e7 --- /dev/null +++ b/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.h @@ -0,0 +1,71 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef VOLUMEGROUPBASEDIALOG_H +#define VOLUMEGROUPBASEDIALOG_H + +#include + +#include + +namespace Ui +{ +class VolumeGroupBaseDialog; +} // namespace Ui + +class QComboBox; +class QLineEdit; +class QListWidget; +class QSpinBox; + +class VolumeGroupBaseDialog : public QDialog +{ + Q_OBJECT + +public: + explicit VolumeGroupBaseDialog( QString& vgName, QVector< const Partition* > pvList, QWidget* parent = nullptr ); + ~VolumeGroupBaseDialog() override; + +protected: + virtual void updateOkButton(); + + void setUsedSizeValue( qint64 usedSize ); + + void setLVQuantity( qint32 lvQuantity ); + + void updateTotalSize(); + + void updateTotalSectors(); + + QVector< const Partition* > checkedItems() const; + + bool isSizeValid() const; + + QString& vgNameValue() const; + + QLineEdit* vgName() const; + + QComboBox* vgType() const; + + QSpinBox* peSize() const; + + QListWidget* pvList() const; + + QPushButton* okButton() const; + +private: + Ui::VolumeGroupBaseDialog* ui; + + QString& m_vgNameValue; + + qint64 m_totalSizeValue; + qint64 m_usedSizeValue; +}; + +#endif // VOLUMEGROUPBASEDIALOG_H diff --git a/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.ui b/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.ui new file mode 100644 index 0000000..f1bb6b2 --- /dev/null +++ b/calamares/src/modules/partition/gui/VolumeGroupBaseDialog.ui @@ -0,0 +1,210 @@ + + + +SPDX-FileCopyrightText: 2018 Caio <caiojcarvalho@gmail.com> +SPDX-License-Identifier: GPL-3.0-or-later + + VolumeGroupBaseDialog + + + + 0 + 0 + 611 + 367 + + + + Create Volume Group + + + + + + List of Physical Volumes + + + + + + + + + + Volume Group Name: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Volume Group Type: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Physical Extent Size: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + MiB + + + 1 + + + 999 + + + 4 + + + + + + + Total Size: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + --- + + + Qt::AlignCenter + + + + + + + Used Size: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + --- + + + Qt::AlignCenter + + + + + + + Total Sectors: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + --- + + + Qt::AlignCenter + + + + + + + Quantity of LVs: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + --- + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + VolumeGroupBaseDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + VolumeGroupBaseDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/calamares/src/modules/partition/jobs/AutoMountManagementJob.cpp b/calamares/src/modules/partition/jobs/AutoMountManagementJob.cpp new file mode 100644 index 0000000..c62c41b --- /dev/null +++ b/calamares/src/modules/partition/jobs/AutoMountManagementJob.cpp @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "AutoMountManagementJob.h" + +#include "utils/Logger.h" + +AutoMountManagementJob::AutoMountManagementJob( bool disable ) + : m_disable( disable ) +{ +} + +QString +AutoMountManagementJob::prettyName() const +{ + return tr( "Managing auto-mount settings…", "@status" ); +} + +Calamares::JobResult +AutoMountManagementJob::exec() +{ + if ( m_stored ) + { + cDebug() << "Restore automount settings"; + Calamares::Partition::automountRestore( m_stored ); + m_stored.reset(); + } + else + { + cDebug() << "Set automount to" << ( m_disable ? "disable" : "enable" ); + m_stored = Calamares::Partition::automountDisable( m_disable ); + } + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/partition/jobs/AutoMountManagementJob.h b/calamares/src/modules/partition/jobs/AutoMountManagementJob.h new file mode 100644 index 0000000..9b7c18c --- /dev/null +++ b/calamares/src/modules/partition/jobs/AutoMountManagementJob.h @@ -0,0 +1,42 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITION_AUTOMOUNTMANAGEMENTJOB_H +#define PARTITION_AUTOMOUNTMANAGEMENTJOB_H + +#include "Job.h" + +#include "partition/AutoMount.h" + +/** + * This job sets automounting to a specific value, and when run a + * second time, **re**sets to the original value. See the documentation + * for Calamares::Partition::automountDisable() for details. + * Use @c true to **disable** automounting. + * + * Effectively: queue the **same** job twice; the first time it runs + * it will set the automount behavior, and the second time it + * restores the original. + * + */ +class AutoMountManagementJob : public Calamares::Job +{ + Q_OBJECT +public: + AutoMountManagementJob( bool disable = true ); + + QString prettyName() const override; + Calamares::JobResult exec() override; + +private: + bool m_disable; + decltype( Calamares::Partition::automountDisable( true ) ) m_stored; +}; + +#endif /* PARTITION_AUTOMOUNTMANAGEMENTJOB_H */ diff --git a/calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.cpp b/calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.cpp new file mode 100644 index 0000000..915cad4 --- /dev/null +++ b/calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.cpp @@ -0,0 +1,86 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016, Lisa Vitolo + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ChangeFilesystemLabelJob.h" + +#include "core/KPMHelpers.h" + +#include "utils/Logger.h" + +#include +#include +#include +#include +#include +#include + +ChangeFilesystemLabelJob::ChangeFilesystemLabelJob( Device* device, Partition* partition, const QString& newLabel ) + : PartitionJob( partition ) + , m_device( device ) + , m_label( newLabel ) +{ +} + + +QString +ChangeFilesystemLabelJob::prettyName() const +{ + return tr( "Set filesystem label on %1", "@title" ).arg( partition()->partitionPath() ); +} + + +QString +ChangeFilesystemLabelJob::prettyDescription() const +{ + return tr( "Set filesystem label %1 to partition %2", "@info" ) + .arg( m_label ) + .arg( partition()->partitionPath() ); +} + + +QString +ChangeFilesystemLabelJob::prettyStatusMessage() const +{ + return tr( "Setting filesystem label %1 to partition %2…", "@status" ) + .arg( m_label ) + .arg( partition()->partitionPath() ); +} + + +Calamares::JobResult +ChangeFilesystemLabelJob::exec() +{ + if ( m_label == partition()->fileSystem().label() ) + { + return Calamares::JobResult::ok(); + } + + // Check for luks device + if ( partition()->fileSystem().type() == FileSystem::Luks ) + { + if ( KPMHelpers::cryptLabel( partition(), m_label ) ) + { + return Calamares::JobResult::ok(); + } + return Calamares::JobResult::error( + tr( "The installer failed to update partition table on disk '%1'.", "@info" ).arg( m_device->name() ) ); + } + + Report report( nullptr ); + SetFileSystemLabelOperation op( *partition(), m_label ); + op.setStatus( Operation::StatusRunning ); + + if ( op.execute( report ) ) + { + return Calamares::JobResult::ok(); + } + return Calamares::JobResult::error( + tr( "The installer failed to update partition table on disk '%1'.", "@info" ).arg( m_device->name() ), + report.toText() ); +} diff --git a/calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.h b/calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.h new file mode 100644 index 0000000..ac39605 --- /dev/null +++ b/calamares/src/modules/partition/jobs/ChangeFilesystemLabelJob.h @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016, Lisa Vitolo + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CHANGEFILESYSTEMLABELJOB_H +#define CHANGEFILESYSTEMLABELJOB_H + +#include "PartitionJob.h" + +#include + +class Device; +class Partition; + +/** + * This job changes the flags on an existing partition. + */ +class ChangeFilesystemLabelJob : public PartitionJob +{ + Q_OBJECT +public: + ChangeFilesystemLabelJob( Device* device, Partition* partition, const QString& newLabel ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + Device* device() const; + +private: + Device* m_device; + QString m_label; +}; + +#endif // CHANGEFILESYSTEMLABELJOB_H diff --git a/calamares/src/modules/partition/jobs/ClearMountsJob.cpp b/calamares/src/modules/partition/jobs/ClearMountsJob.cpp new file mode 100644 index 0000000..b4ebfeb --- /dev/null +++ b/calamares/src/modules/partition/jobs/ClearMountsJob.cpp @@ -0,0 +1,417 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Kevin Kofler + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ClearMountsJob.h" + +#include "core/PartitionInfo.h" + +#include "partition/PartitionIterator.h" +#include "partition/Sync.h" +#include "utils/Logger.h" +#include "utils/String.h" + +// KPMcore +#include +#include +#include + +#include +#include +#include +#include + +using Calamares::Partition::PartitionIterator; + + +/** @brief Returns list of partitions on a given @p deviceName + * + * The @p deviceName is a (whole-block) device, like "sda", and the partitions + * returned are then "sdaX". The whole-block device itself is ignored, if + * present. Partitions are returned with their full /dev/ path (e.g. /dev/sda1). + * + * The format for /etc/partitions is, e.g. + * major minor #blocks name + * 8 0 33554422 sda + * 8 1 33554400 sda1 + */ +STATICTEST QStringList +getPartitionsForDevice( const QString& deviceName ) +{ + QStringList partitions; + + QFile dev_partitions( "/proc/partitions" ); + if ( dev_partitions.open( QFile::ReadOnly ) ) + { + cDebug() << "Reading from" << dev_partitions.fileName() << "looking for" << deviceName; + QTextStream in( &dev_partitions ); + (void)in.readLine(); // That's the header line, skip it + while ( !in.atEnd() ) + { + // The fourth column (index from 0, so index 3) is the name of the device; + // keep it if it is followed by something. + QStringList columns = in.readLine().split( ' ', SplitSkipEmptyParts ); + if ( ( columns.count() >= 4 ) && ( columns[ 3 ].startsWith( deviceName ) ) + && ( columns[ 3 ] != deviceName ) ) + { + partitions.append( QStringLiteral( "/dev/" ) + columns[ 3 ] ); + } + } + } + else + { + cDebug() << "Could not open" << dev_partitions.fileName(); + } + + return partitions; +} + +STATICTEST QStringList +getSwapsForDevice( const QString& deviceName ) +{ + QProcess process; + + // Build a list of partitions of type 82 (Linux swap / Solaris). + // We then need to clear them just in case they contain something resumable from a + // previous suspend-to-disk. + QStringList swapPartitions; + process.start( "sfdisk", { "-d", deviceName } ); + process.waitForFinished(); + // Sample output: + // % sudo sfdisk -d /dev/sda + // label: dos + // label-id: 0x000ced89 + // device: /dev/sda + // unit: sectors + + // /dev/sda1 : start= 63, size= 29329345, type=83, bootable + // /dev/sda2 : start= 29331456, size= 2125824, type=82 + + swapPartitions = QString::fromLocal8Bit( process.readAllStandardOutput() ).split( '\n' ); + swapPartitions = swapPartitions.filter( "type=82" ); + for ( QStringList::iterator it = swapPartitions.begin(); it != swapPartitions.end(); ++it ) + { + *it = ( *it ).simplified().split( ' ' ).first(); + } + + return swapPartitions; +} + +static inline bool +isSpecial( const QString& baseName ) +{ + // Fedora live images use /dev/mapper/live-* internally. We must not + // unmount those devices, because they are used by the live image and + // because we need /dev/mapper/live-base in the unpackfs module. + const bool specialForFedora = baseName.startsWith( "live-" ); + + // Exclude /dev/mapper/control + const bool specialMapperControl = baseName == "control"; + + // When ventoy is used, ventoy uses the /dev/mapper/ventoy device. We + // must not unmount this device, because it is used by the live image + // and because we need /dev/mapper/ventoy in the unpackfs module. + const bool specialVentoy = baseName == "ventoy"; + + return specialForFedora || specialMapperControl || specialVentoy; +} + +static inline bool +matchesExceptions( const QStringList& mapperExceptions, const QString& basename ) +{ + for ( const auto& e : mapperExceptions ) + { + if ( basename == e ) + { + return true; + } + if ( e.endsWith( '*' ) && basename.startsWith( e.left( e.length() - 1 ) ) ) + { + return true; + } + } + return false; +} + +/** @brief Returns a list of unneeded crypto devices + * + * These are the crypto devices to unmount and close; some are "needed" + * for system operation: on Fedora, the live- mappers are special. + * Some other devices are special, too, so those do not end up in + * the list. + */ +STATICTEST QStringList +getCryptoDevices( const QStringList& mapperExceptions ) +{ + QDir mapperDir( "/dev/mapper" ); + const QFileInfoList fiList = mapperDir.entryInfoList( QDir::Files ); + QStringList list; + for ( const QFileInfo& fi : fiList ) + { + QString baseName = fi.baseName(); + if ( isSpecial( baseName ) || matchesExceptions( mapperExceptions, baseName ) ) + { + continue; + } + list.append( fi.absoluteFilePath() ); + } + return list; +} + +STATICTEST QStringList +getLVMVolumes() +{ + QProcess process; + + // First we umount all LVM logical volumes we can find + process.start( "lvscan", { "-a" } ); + process.waitForFinished(); + if ( process.exitCode() == 0 ) //means LVM2 tools are installed + { + QStringList lvscanLines = QString::fromLocal8Bit( process.readAllStandardOutput() ).split( '\n' ); + // Get the second column (`value(1)`) sinec that is the device name, + // remove quoting. + std::transform( lvscanLines.begin(), + lvscanLines.end(), + lvscanLines.begin(), + []( const QString& lvscanLine ) + { return lvscanLine.simplified().split( ' ' ).value( 1 ).replace( '\'', "" ); } ); + return lvscanLines; + } + else + { + cWarning() << "this system does not seem to have LVM2 tools."; + } + return QStringList(); +} +STATICTEST QStringList +getPVGroups( const QString& deviceName ) +{ + QProcess process; + // Then we go looking for volume groups that use this device for physical volumes + process.start( "pvdisplay", { "-C", "--noheadings" } ); + process.waitForFinished(); + if ( process.exitCode() == 0 ) //means LVM2 tools are installed + { + QString pvdisplayOutput = process.readAllStandardOutput(); + if ( !pvdisplayOutput.simplified().isEmpty() ) //means there is at least one LVM PV + { + QSet< QString > vgSet; + + const QStringList pvdisplayLines = pvdisplayOutput.split( '\n' ); + for ( const QString& pvdisplayLine : pvdisplayLines ) + { + QString pvPath = pvdisplayLine.simplified().split( ' ' ).value( 0 ); + QString vgName = pvdisplayLine.simplified().split( ' ' ).value( 1 ); + if ( !pvPath.contains( deviceName ) ) + { + continue; + } + + vgSet.insert( vgName ); + } + return QStringList { vgSet.cbegin(), vgSet.cend() }; + } + } + else + { + cWarning() << "this system does not seem to have LVM2 tools."; + } + return QStringList(); +} + +/* + * The tryX() free functions, below, return an empty QString on + * failure, or a non-empty QString on success. The string is + * meant **only** for debugging and is not displayed to the user, + * which is why no translation is applied. + * + * The MessageAndPath class stores a C-style pointer to a character + * array -- from QT_TRANSLATE_NOOP() -- and a path to substitute into it. + * + * When the tryX() functions return an "empty string", it is an + * empty MessageAndPath which acts like an empty string (in particular, + * isEmpty() is true). + */ + +class MessageAndPath +{ +public: + ///@brief An unsuccessful attempt at something + MessageAndPath() {} + ///@brief A success at doing @p thing to @p path + MessageAndPath( const char* thing, const QString& path ) + : m_message( thing ) + , m_path( path ) + { + } + + bool isEmpty() const { return !m_message; } + + explicit operator QString() const + { + return isEmpty() ? QString() : QCoreApplication::translate( "ClearMountsJob", m_message ).arg( m_path ); + } + +private: + const char* m_message = nullptr; + QString m_path; +}; + +STATICTEST inline QDebug& +operator<<( QDebug& s, const MessageAndPath& m ) +{ + if ( m.isEmpty() ) + { + return s; + } + return s << QString( m ); +} + + +///@brief Returns a debug-string if @p partPath could be unmounted +STATICTEST MessageAndPath +tryUmount( const QString& partPath ) +{ + QProcess process; + process.start( "umount", { partPath } ); + process.waitForFinished(); + if ( process.exitCode() == 0 ) + { + return { QT_TRANSLATE_NOOP( "ClearMountsJob", "Successfully unmounted %1." ), partPath }; + } + + process.start( "swapoff", { partPath } ); + process.waitForFinished(); + if ( process.exitCode() == 0 ) + { + return { QT_TRANSLATE_NOOP( "ClearMountsJob", "Successfully disabled swap %1." ), partPath }; + } + + return {}; +} + +///@brief Returns a debug-string if @p partPath was swap and could be cleared +STATICTEST MessageAndPath +tryClearSwap( const QString& partPath ) +{ + QProcess process; + process.start( "blkid", { "-s", "UUID", "-o", "value", partPath } ); + process.waitForFinished(); + QString swapPartUuid = QString::fromLocal8Bit( process.readAllStandardOutput() ).simplified(); + if ( process.exitCode() != 0 || swapPartUuid.isEmpty() ) + { + return {}; + } + + process.start( "mkswap", { "-U", swapPartUuid, partPath } ); + process.waitForFinished(); + if ( process.exitCode() != 0 ) + { + return {}; + } + + return { QT_TRANSLATE_NOOP( "ClearMountsJob", "Successfully cleared swap %1." ), partPath }; +} + +///@brief Returns a debug-string if @p mapperPath could be closed +STATICTEST MessageAndPath +tryCryptoClose( const QString& mapperPath ) +{ + /* ignored */ tryUmount( mapperPath ); + + QProcess process; + process.start( "cryptsetup", { "close", mapperPath } ); + process.waitForFinished(); + if ( process.exitCode() == 0 ) + { + return { QT_TRANSLATE_NOOP( "ClearMountsJob", "Successfully closed mapper device %1." ), mapperPath }; + } + + return {}; +} + +STATICTEST MessageAndPath +tryVGDisable( const QString& vgName ) +{ + QProcess vgProcess; + vgProcess.start( "vgchange", { "-an", vgName } ); + vgProcess.waitForFinished(); + return ( vgProcess.exitCode() == 0 ) + ? MessageAndPath { QT_TRANSLATE_NOOP( "ClearMountsJob", "Successfully disabled volume group %1." ), vgName } + : MessageAndPath {}; +} + +///@brief Apply @p f to all the @p paths, appending successes to @p news +template < typename F > +void +apply( const QStringList& paths, F f, QList< MessageAndPath >& news ) +{ + for ( const QString& p : std::as_const( paths ) ) + { + auto n = f( p ); + if ( !n.isEmpty() ) + { + news.append( n ); + } + } +} + +STATICTEST QStringList +stringify( const QList< MessageAndPath >& news ) +{ + QStringList l; + for ( const auto& m : std::as_const( news ) ) + { + l << QString( m ); + } + return l; +} + +ClearMountsJob::ClearMountsJob( Device* device ) + : Calamares::Job() + , m_deviceNode( device->deviceNode() ) +{ +} + +QString +ClearMountsJob::prettyName() const +{ + return tr( "Clear mounts for partitioning operations on %1", "@title" ).arg( m_deviceNode ); +} + +QString +ClearMountsJob::prettyStatusMessage() const +{ + return tr( "Clearing mounts for partitioning operations on %1…", "@status" ).arg( m_deviceNode ); +} + +Calamares::JobResult +ClearMountsJob::exec() +{ + const QString deviceName = m_deviceNode.split( '/' ).last(); + Calamares::Partition::Syncer s; + QList< MessageAndPath > goodNews; + + apply( getCryptoDevices( m_mapperExceptions ), tryCryptoClose, goodNews ); + apply( getLVMVolumes(), tryUmount, goodNews ); + apply( getPVGroups( deviceName ), tryVGDisable, goodNews ); + + apply( getCryptoDevices( m_mapperExceptions ), tryCryptoClose, goodNews ); + apply( getPartitionsForDevice( deviceName ), tryUmount, goodNews ); + apply( getSwapsForDevice( m_deviceNode ), tryClearSwap, goodNews ); + + Calamares::JobResult ok = Calamares::JobResult::ok(); + ok.setMessage( tr( "Cleared all mounts for %1" ).arg( m_deviceNode ) ); + ok.setDetails( stringify( goodNews ).join( "\n" ) ); + cDebug() << "ClearMountsJob finished. Here's what was done:" << Logger::DebugListT< MessageAndPath >( goodNews ); + + return ok; +} diff --git a/calamares/src/modules/partition/jobs/ClearMountsJob.h b/calamares/src/modules/partition/jobs/ClearMountsJob.h new file mode 100644 index 0000000..44fc815 --- /dev/null +++ b/calamares/src/modules/partition/jobs/ClearMountsJob.h @@ -0,0 +1,60 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CLEARMOUNTSJOB_H +#define CLEARMOUNTSJOB_H + +#include "Job.h" + +class Device; + +/** + * This job tries to free all mounts for the given device, so partitioning + * operations can proceed. + * + * - partitions on the device are unmounted + * - swap on the device is disabled and cleared + * - physical volumes for LVM on the device are disabled + * + * In addition, regardless of device: + * - almost all(*) /dev/mapper entries (crypto / LUKS, also LVM) are closed + * - all logical volumes for LVM are unmounted + * Exceptions to "all /dev/mapper" may be configured through + * the setMapperExceptions() method. Pass in names of mapper + * files that should not be closed (e.g. "myvg-mylv"). + * + * (*) Some exceptions always exist: /dev/mapper/control is never + * closed. /dev/mapper/live-* is never closed. /dev/mapper/ventoy + * is never closed. + * + */ +class ClearMountsJob : public Calamares::Job +{ + Q_OBJECT +public: + /** @brief Creates a job freeing mounts on @p device + * + * No ownership is transferred; the @p device is used only to access + * the device node (name). + */ + explicit ClearMountsJob( Device* device ); + + QString prettyName() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + ///@brief Sets the list of exceptions (names) when closing /dev/mapper + void setMapperExceptions( const QStringList& names ) { m_mapperExceptions = names; } + +private: + const QString m_deviceNode; + QStringList m_mapperExceptions; +}; + +#endif // CLEARMOUNTSJOB_H diff --git a/calamares/src/modules/partition/jobs/ClearTempMountsJob.cpp b/calamares/src/modules/partition/jobs/ClearTempMountsJob.cpp new file mode 100644 index 0000000..f231983 --- /dev/null +++ b/calamares/src/modules/partition/jobs/ClearTempMountsJob.cpp @@ -0,0 +1,76 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ClearTempMountsJob.h" + +#include "partition/Mount.h" +#include "utils/Logger.h" +#include "utils/String.h" + +#include + +#include +#include +#include + +#include + +ClearTempMountsJob::ClearTempMountsJob() + : Calamares::Job() +{ +} + + +QString +ClearTempMountsJob::prettyName() const +{ + return tr( "Clearing all temporary mounts…", "@status" ); +} + + +QString +ClearTempMountsJob::prettyStatusMessage() const +{ + return tr( "Clearing all temporary mounts…", "@status" ); +} + + +Calamares::JobResult +ClearTempMountsJob::exec() +{ + Logger::Once o; + // Fetch a list of current mounts to Calamares temporary directories. + using MtabInfo = Calamares::Partition::MtabInfo; + auto targetMounts = MtabInfo::fromMtabFilteredByPrefix( QStringLiteral( "/tmp/calamares-" ) ); + + if ( targetMounts.isEmpty() ) + { + return Calamares::JobResult::ok(); + } + std::sort( targetMounts.begin(), targetMounts.end(), MtabInfo::mountPointOrder ); + + QStringList goodNews; + for ( const auto& m : std::as_const( targetMounts ) ) + { + cDebug() << o << "Will try to umount path" << m.mountPoint; + if ( Calamares::Partition::unmount( m.mountPoint, { "-lv" } ) == 0 ) + { + // Returns the program's exit code, so 0 is success + goodNews.append( QString( "Successfully unmounted %1." ).arg( m.mountPoint ) ); + } + } + + Calamares::JobResult ok = Calamares::JobResult::ok(); + ok.setMessage( tr( "Cleared all temporary mounts." ) ); + ok.setDetails( goodNews.join( "\n" ) ); + + cDebug() << o << "ClearTempMountsJob finished. Here's what was done:\n" << Logger::DebugList( goodNews ); + + return ok; +} diff --git a/calamares/src/modules/partition/jobs/ClearTempMountsJob.h b/calamares/src/modules/partition/jobs/ClearTempMountsJob.h new file mode 100644 index 0000000..0726975 --- /dev/null +++ b/calamares/src/modules/partition/jobs/ClearTempMountsJob.h @@ -0,0 +1,31 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CLEARTEMPMOUNTSJOB_H +#define CLEARTEMPMOUNTSJOB_H + +#include "Job.h" + +class Device; + +/** + * This job tries to free all temporary mounts used by Calamares, so partitioning + * operations can proceed. + */ +class ClearTempMountsJob : public Calamares::Job +{ + Q_OBJECT +public: + explicit ClearTempMountsJob(); + QString prettyName() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; +}; + +#endif // CLEARTEMPMOUNTSJOB_H diff --git a/calamares/src/modules/partition/jobs/CreatePartitionJob.cpp b/calamares/src/modules/partition/jobs/CreatePartitionJob.cpp new file mode 100644 index 0000000..429bf26 --- /dev/null +++ b/calamares/src/modules/partition/jobs/CreatePartitionJob.cpp @@ -0,0 +1,283 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CreatePartitionJob.h" + +#include "core/KPMHelpers.h" +#include "core/PartitionInfo.h" + +#include "partition/FileSystem.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Units.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using Calamares::Partition::untranslatedFS; +using Calamares::Partition::userVisibleFS; + +/** @brief Create + * + * Uses sfdisk to remove @p partition. This should only be used in cases + * where using kpmcore to remove the partition would not be appropriate + * + */ +static Calamares::JobResult +createZfs( Partition* partition, Device* device ) +{ + auto r = Calamares::System::instance()->runCommand( + { "sh", + "-c", + "echo start=" + QString::number( partition->firstSector() ) + " size=" + + QString::number( partition->length() ) + " | sfdisk --append --force " + partition->devicePath() }, + std::chrono::seconds( 5 ) ); + if ( r.getExitCode() != 0 ) + { + return Calamares::JobResult::error( + QCoreApplication::translate( CreatePartitionJob::staticMetaObject.className(), + "Failed to create partition" ), + QCoreApplication::translate( CreatePartitionJob::staticMetaObject.className(), + "Failed to create zfs partition with output: " + + r.getOutput().toLocal8Bit() ) ); + } + + // Now we need to do some things that would normally be done by kpmcore + + // First we get the device node from the output and set it as the partition path + QString deviceNode; + { + QRegularExpression re( QStringLiteral( "Created a new partition (\\d+)" ) ); + QRegularExpressionMatch rem = re.match( r.getOutput() ); + + if ( rem.hasMatch() ) + { + if ( partition->devicePath().back().isDigit() ) + { + deviceNode = partition->devicePath() + QLatin1Char( 'p' ) + rem.captured( 1 ); + } + else + { + deviceNode = partition->devicePath() + rem.captured( 1 ); + } + } + partition->setPartitionPath( deviceNode ); + } + // If it is a gpt device, set the partition UUID + if ( device->partitionTable()->type() == PartitionTable::gpt && partition->uuid().isEmpty() ) + { + r = Calamares::System::instance()->runCommand( + { "sfdisk", "--list", "--output", "Device,UUID", partition->devicePath() }, std::chrono::seconds( 5 ) ); + if ( r.getExitCode() == 0 ) + { + QRegularExpression re( deviceNode + QStringLiteral( " +(.+)" ) ); + QRegularExpressionMatch rem = re.match( r.getOutput() ); + + if ( rem.hasMatch() ) + { + partition->setUUID( rem.captured( 1 ) ); + } + } + } + + return Calamares::JobResult::ok(); +} + +CreatePartitionJob::CreatePartitionJob( Device* device, Partition* partition ) + : PartitionJob( partition ) + , m_device( device ) +{ +} + +static QString +prettyGptType( const Partition* partition ) +{ + static const QMap< QString, QString > gptTypePrettyStrings = { + { "44479540-f297-41b2-9af7-d131d5f0458a", "Linux Root Partition (x86)" }, + { "4f68bce3-e8cd-4db1-96e7-fbcaf984b709", "Linux Root Partition (x86-64)" }, + { "69dad710-2ce4-4e3c-b16c-21a1d49abed3", "Linux Root Partition (32-bit ARM)" }, + { "b921b045-1df0-41c3-af44-4c6f280d3fae", "Linux Root Partition (64-bit ARM)" }, + { "993d8d3d-f80e-4225-855a-9daf8ed7ea97", "Linux Root Partition (Itanium/IA-64)" }, + { "d13c5d3b-b5d1-422a-b29f-9454fdc89d76", "Linux Root Verity Partition (x86)" }, + { "2c7357ed-ebd2-46d9-aec1-23d437ec2bf5", "Linux Root Verity Partition (x86-64)" }, + { "7386cdf2-203c-47a9-a498-f2ecce45a2d6", "Linux Root Verity Partition (32-bit ARM)" }, + { "df3300ce-d69f-4c92-978c-9bfb0f38d820", "Linux Root Verity Partition (64-bit ARM/AArch64)" }, + { "86ed10d5-b607-45bb-8957-d350f23d0571", "Linux Root Verity Partition (Itanium/IA-64)" }, + { "75250d76-8cc6-458e-bd66-bd47cc81a812", "Linux /usr Partition (x86)" }, + { "8484680c-9521-48c6-9c11-b0720656f69e", "Linux /usr Partition (x86-64)" }, + { "7d0359a3-02b3-4f0a-865c-654403e70625", "Linux /usr Partition (32-bit ARM)" }, + { "b0e01050-ee5f-4390-949a-9101b17104e9", "Linux /usr Partition (64-bit ARM/AArch64)" }, + { "4301d2a6-4e3b-4b2a-bb94-9e0b2c4225ea", "Linux /usr Partition (Itanium/IA-64)" }, + { "8f461b0d-14ee-4e81-9aa9-049b6fb97abd", "Linux /usr Verity Partition (x86)" }, + { "77ff5f63-e7b6-4633-acf4-1565b864c0e6", "Linux /usr Verity Partition (x86-64)" }, + { "c215d751-7bcd-4649-be90-6627490a4c05", "Linux /usr Verity Partition (32-bit ARM)" }, + { "6e11a4e7-fbca-4ded-b9e9-e1a512bb664e", "Linux /usr Verity Partition (64-bit ARM/AArch64)" }, + { "6a491e03-3be7-4545-8e38-83320e0ea880", "Linux /usr Verity Partition (Itanium/IA-64)" }, + { "933ac7e1-2eb4-4f13-b844-0e14e2aef915", "Linux Home Partition" }, + { "3b8f8425-20e0-4f3b-907f-1a25a76f98e8", "Linux Server Data Partition" }, + { "4d21b016-b534-45c2-a9fb-5c16e091fd2d", "Linux Variable Data Partition" }, + { "7ec6f557-3bc5-4aca-b293-16ef5df639d1", "Linux Temporary Data Partition" }, + { "0657fd6d-a4ab-43c4-84e5-0933c84b4f4f", "Linux Swap" }, + { "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", "EFI System Partition" }, + { "bc13c2ff-59e6-4262-a352-b275fd6f7172", "Extended Boot Loader Partition" }, + { "0fc63daf-8483-4772-8e79-3d69d8477de4", "Other Data Partitions" }, + { "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", "Microsoft basic data" }, + }; + + auto type = partition->type(); + return gptTypePrettyStrings.value( type.toLower(), type ); +} + +static QString +prettyGptEntries( const Partition* partition ) +{ + if ( !partition ) + { + return QString(); + } + + QStringList list; + + if ( !partition->label().isEmpty() ) + { + list += partition->label(); + } + + QString type = prettyGptType( partition ); + if ( !type.isEmpty() ) + { + list += type; + } + + return list.join( QStringLiteral( ", " ) ); +} + +QString +CreatePartitionJob::prettyName() const +{ + const PartitionTable* table = Calamares::Partition::getPartitionTable( m_partition ); + if ( table && table->type() == PartitionTable::TableType::gpt ) + { + QString entries = prettyGptEntries( m_partition ); + if ( !entries.isEmpty() ) + { + return tr( "Create new %1MiB partition on %3 (%2) with entries %4", "@title" ) + .arg( Calamares::BytesToMiB( m_partition->capacity() ) ) + .arg( m_device->name() ) + .arg( m_device->deviceNode() ) + .arg( entries ); + } + else + { + return tr( "Create new %1MiB partition on %3 (%2)", "@title" ) + .arg( Calamares::BytesToMiB( m_partition->capacity() ) ) + .arg( m_device->name() ) + .arg( m_device->deviceNode() ); + } + } + + return tr( "Create new %2MiB partition on %4 (%3) with file system %1", "@title" ) + .arg( userVisibleFS( m_partition->fileSystem() ) ) + .arg( Calamares::BytesToMiB( m_partition->capacity() ) ) + .arg( m_device->name() ) + .arg( m_device->deviceNode() ); +} + +QString +CreatePartitionJob::prettyDescription() const +{ + const PartitionTable* table = Calamares::Partition::getPartitionTable( m_partition ); + if ( table && table->type() == PartitionTable::TableType::gpt ) + { + QString entries = prettyGptEntries( m_partition ); + if ( !entries.isEmpty() ) + { + return tr( "Create new %1MiB partition on %3 (%2) with entries " + "%4", + "@info" ) + .arg( Calamares::BytesToMiB( m_partition->capacity() ) ) + .arg( m_device->name() ) + .arg( m_device->deviceNode() ) + .arg( entries ); + } + else + { + return tr( "Create new %1MiB partition on %3 (%2)", "@info" ) + .arg( Calamares::BytesToMiB( m_partition->capacity() ) ) + .arg( m_device->name() ) + .arg( m_device->deviceNode() ); + } + } + + return tr( "Create new %2MiB partition on %4 " + "(%3) with file system %1", + "@info" ) + .arg( userVisibleFS( m_partition->fileSystem() ) ) + .arg( Calamares::BytesToMiB( m_partition->capacity() ) ) + .arg( m_device->name() ) + .arg( m_device->deviceNode() ); +} + +QString +CreatePartitionJob::prettyStatusMessage() const +{ + const PartitionTable* table = Calamares::Partition::getPartitionTable( m_partition ); + if ( table && table->type() == PartitionTable::TableType::gpt ) + { + QString type = prettyGptType( m_partition ); + if ( type.isEmpty() ) + { + type = m_partition->label(); + } + if ( type.isEmpty() ) + { + type = userVisibleFS( m_partition->fileSystem() ); + } + + return tr( "Creating new %1 partition on %2…", "@status" ).arg( type ).arg( m_device->deviceNode() ); + } + + return tr( "Creating new %1 partition on %2…", "@status" ) + .arg( userVisibleFS( m_partition->fileSystem() ) ) + .arg( m_device->deviceNode() ); +} + +Calamares::JobResult +CreatePartitionJob::exec() +{ + // kpmcore doesn't currently handle this case properly so for now, we manually create the partion + // The zfs module can later deal with creating a zpool in the partition + if ( m_partition->fileSystem().type() == FileSystem::Type::Zfs ) + { + return createZfs( m_partition, m_device ); + } + + return KPMHelpers::execute( + NewOperation( *m_device, m_partition ), + tr( "The installer failed to create partition on disk '%1'.", "@info" ).arg( m_device->name() ) ); +} + +void +CreatePartitionJob::updatePreview() +{ + m_device->partitionTable()->removeUnallocated(); + m_partition->parent()->insert( m_partition ); + m_device->partitionTable()->updateUnallocated( *m_device ); +} diff --git a/calamares/src/modules/partition/jobs/CreatePartitionJob.h b/calamares/src/modules/partition/jobs/CreatePartitionJob.h new file mode 100644 index 0000000..3d61998 --- /dev/null +++ b/calamares/src/modules/partition/jobs/CreatePartitionJob.h @@ -0,0 +1,44 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CREATEPARTITIONJOB_H +#define CREATEPARTITIONJOB_H + +#include "PartitionJob.h" + +class Device; +class Partition; +class FileSystem; + +/** + * Creates a partition on a device. + * + * This job does two things: + * 1. Create the partition + * 2. Create the filesystem on the partition + */ +class CreatePartitionJob : public PartitionJob +{ + Q_OBJECT +public: + CreatePartitionJob( Device* device, Partition* partition ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + void updatePreview(); + Device* device() const { return m_device; } + +private: + Device* m_device; +}; + +#endif /* CREATEPARTITIONJOB_H */ diff --git a/calamares/src/modules/partition/jobs/CreatePartitionTableJob.cpp b/calamares/src/modules/partition/jobs/CreatePartitionTableJob.cpp new file mode 100644 index 0000000..f75e647 --- /dev/null +++ b/calamares/src/modules/partition/jobs/CreatePartitionTableJob.cpp @@ -0,0 +1,106 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CreatePartitionTableJob.h" + +#include "partition/PartitionIterator.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include "core/KPMHelpers.h" + +#include +#include +#include +#include +#include +#include + +// Qt +#include + +using Calamares::Partition::PartitionIterator; + +CreatePartitionTableJob::CreatePartitionTableJob( Device* device, PartitionTable::TableType type ) + : m_device( device ) + , m_type( type ) +{ +} + +QString +CreatePartitionTableJob::prettyName() const +{ + return tr( "Creating new %1 partition table on %2…", "@status" ) + .arg( PartitionTable::tableTypeToName( m_type ) ) + .arg( m_device->deviceNode() ); +} + +QString +CreatePartitionTableJob::prettyDescription() const +{ + return tr( "Creating new %1 partition table on %2 (%3)…", "@status" ) + .arg( PartitionTable::tableTypeToName( m_type ).toUpper() ) + .arg( m_device->deviceNode() ) + .arg( m_device->name() ); +} + +QString +CreatePartitionTableJob::prettyStatusMessage() const +{ + return tr( "Creating new %1 partition table on %2…", "@status" ) + .arg( PartitionTable::tableTypeToName( m_type ).toUpper() ) + .arg( m_device->deviceNode() ); +} + +Calamares::JobResult +CreatePartitionTableJob::exec() +{ + + PartitionTable* table = m_device->partitionTable(); + + if ( Logger::logLevelEnabled( Logger::LOGDEBUG ) ) + { + cDebug() << "Creating new partition table of type" << table->typeName() << ", uncommitted partitions:"; + for ( auto it = PartitionIterator::begin( table ); it != PartitionIterator::end( table ); ++it ) + { + cDebug() << Logger::SubEntry << ( ( *it ) ? ( *it )->deviceNode() : QString( "" ) ); + } + + auto lsblkResult = Calamares::System::runCommand( { "lsblk" }, std::chrono::seconds( 30 ) ); + cDebug() << Logger::SubEntry << "lsblk output:\n" << Logger::NoQuote << lsblkResult.getOutput(); + + auto mountResult = Calamares::System::runCommand( { "mount" }, std::chrono::seconds( 30 ) ); + cDebug() << Logger::SubEntry << "mount output:\n" << Logger::NoQuote << mountResult.getOutput(); + } + + return KPMHelpers::execute( + CreatePartitionTableOperation( *m_device, table ), + tr( "The installer failed to create a partition table on %1." ).arg( m_device->name() ) ); +} + +void +CreatePartitionTableJob::updatePreview() +{ + // Device takes ownership of its table, but does not destroy the current + // one when setPartitionTable() is called, so do it ourself + delete m_device->partitionTable(); + m_device->setPartitionTable( createTable() ); + m_device->partitionTable()->updateUnallocated( *m_device ); +} + +PartitionTable* +CreatePartitionTableJob::createTable() +{ + cDebug() << "CreatePartitionTableJob::createTable trying to make table for device" << m_device->deviceNode(); + return new PartitionTable( m_type, + PartitionTable::defaultFirstUsable( *m_device, m_type ), + PartitionTable::defaultLastUsable( *m_device, m_type ) ); +} diff --git a/calamares/src/modules/partition/jobs/CreatePartitionTableJob.h b/calamares/src/modules/partition/jobs/CreatePartitionTableJob.h new file mode 100644 index 0000000..4acb1e5 --- /dev/null +++ b/calamares/src/modules/partition/jobs/CreatePartitionTableJob.h @@ -0,0 +1,48 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CREATEPARTITIONTABLEJOB_H +#define CREATEPARTITIONTABLEJOB_H + +#include "Job.h" +#include "partition/KPMManager.h" + +// KPMcore +#include + +class Device; + +/** + * Creates a partition table on a device. It supports MBR and GPT partition + * tables. + * + * This wipes all the data from the device. + */ +class CreatePartitionTableJob : public Calamares::Job +{ + Q_OBJECT +public: + CreatePartitionTableJob( Device* device, PartitionTable::TableType type ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + void updatePreview(); + Device* device() const { return m_device; } + +private: + Calamares::Partition::KPMManager m_kpmcore; + Device* m_device; + PartitionTable::TableType m_type; + PartitionTable* createTable(); +}; + +#endif /* CREATEPARTITIONTABLEJOB_H */ diff --git a/calamares/src/modules/partition/jobs/CreateVolumeGroupJob.cpp b/calamares/src/modules/partition/jobs/CreateVolumeGroupJob.cpp new file mode 100644 index 0000000..638d05e --- /dev/null +++ b/calamares/src/modules/partition/jobs/CreateVolumeGroupJob.cpp @@ -0,0 +1,70 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CreateVolumeGroupJob.h" + +#include "core/KPMHelpers.h" + +#include +#include +#include +#include + +CreateVolumeGroupJob::CreateVolumeGroupJob( Device*, + QString& vgName, + QVector< const Partition* > pvList, + const qint32 peSize ) + : m_vgName( vgName ) + , m_pvList( pvList ) + , m_peSize( peSize ) +{ +} + +QString +CreateVolumeGroupJob::prettyName() const +{ + return tr( "Creating new volume group named %1…", "@status" ).arg( m_vgName ); +} + +QString +CreateVolumeGroupJob::prettyDescription() const +{ + return tr( "Creating new volume group named %1…", "@status" ).arg( m_vgName ); +} + +QString +CreateVolumeGroupJob::prettyStatusMessage() const +{ + return tr( "Creating new volume group named %1…", "@status" ).arg( m_vgName ); +} + +Calamares::JobResult +CreateVolumeGroupJob::exec() +{ + return KPMHelpers::execute( CreateVolumeGroupOperation( m_vgName, m_pvList, m_peSize ), + tr( "The installer failed to create a volume group named '%1'." ).arg( m_vgName ) ); +} + +void +CreateVolumeGroupJob::updatePreview() +{ + LvmDevice::s_DirtyPVs << m_pvList; +} + +void +CreateVolumeGroupJob::undoPreview() +{ + for ( const auto& pv : m_pvList ) + { + if ( LvmDevice::s_DirtyPVs.contains( pv ) ) + { + LvmDevice::s_DirtyPVs.removeAll( pv ); + } + } +} diff --git a/calamares/src/modules/partition/jobs/CreateVolumeGroupJob.h b/calamares/src/modules/partition/jobs/CreateVolumeGroupJob.h new file mode 100644 index 0000000..c4b4c36 --- /dev/null +++ b/calamares/src/modules/partition/jobs/CreateVolumeGroupJob.h @@ -0,0 +1,42 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CREATEVOLUMEGROUPJOB_H +#define CREATEVOLUMEGROUPJOB_H + +#include "Job.h" +#include "partition/KPMManager.h" + +#include + +class Device; +class Partition; + +class CreateVolumeGroupJob : public Calamares::Job +{ + Q_OBJECT +public: + CreateVolumeGroupJob( Device*, QString& vgName, QVector< const Partition* > pvList, const qint32 peSize ); + + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + void updatePreview(); + void undoPreview(); + +private: + Calamares::Partition::KPMManager m_kpmcore; + QString m_vgName; + QVector< const Partition* > m_pvList; + qint32 m_peSize; +}; + +#endif // CREATEVOLUMEGROUPJOB_H diff --git a/calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.cpp b/calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.cpp new file mode 100644 index 0000000..6a4203b --- /dev/null +++ b/calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.cpp @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "DeactivateVolumeGroupJob.h" + +#include "core/KPMHelpers.h" + +#include +#include +#include + +DeactivateVolumeGroupJob::DeactivateVolumeGroupJob( LvmDevice* device ) + : m_device( device ) +{ +} + +QString +DeactivateVolumeGroupJob::prettyName() const +{ + return tr( "Deactivating volume group named %1…", "@status" ).arg( m_device->name() ); +} + +QString +DeactivateVolumeGroupJob::prettyDescription() const +{ + return tr( "Deactivating volume group named %1…", "@status" ).arg( m_device->name() ); +} + +QString +DeactivateVolumeGroupJob::prettyStatusMessage() const +{ + return tr( "Deactivating volume group named %1…", "@status" ).arg( m_device->name() ); +} + +Calamares::JobResult +DeactivateVolumeGroupJob::exec() +{ + DeactivateVolumeGroupOperation op( *m_device ); + auto r = KPMHelpers::execute( + op, tr( "The installer failed to deactivate a volume group named %1." ).arg( m_device->name() ) ); + if ( r ) + { + op.preview(); + } + return r; +} diff --git a/calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.h b/calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.h new file mode 100644 index 0000000..175c4c6 --- /dev/null +++ b/calamares/src/modules/partition/jobs/DeactivateVolumeGroupJob.h @@ -0,0 +1,34 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef DEACTIVATEVOLUMEGROUPJOB_H +#define DEACTIVATEVOLUMEGROUPJOB_H + +#include "Job.h" +#include "partition/KPMManager.h" + +class LvmDevice; + +class DeactivateVolumeGroupJob : public Calamares::Job +{ + Q_OBJECT +public: + DeactivateVolumeGroupJob( LvmDevice* device ); + + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + Calamares::Partition::KPMManager m_kpmcore; + LvmDevice* m_device; +}; + +#endif // DEACTIVATEVOLUMEGROUPJOB_H diff --git a/calamares/src/modules/partition/jobs/DeletePartitionJob.cpp b/calamares/src/modules/partition/jobs/DeletePartitionJob.cpp new file mode 100644 index 0000000..f806dab --- /dev/null +++ b/calamares/src/modules/partition/jobs/DeletePartitionJob.cpp @@ -0,0 +1,120 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "DeletePartitionJob.h" + +#include "core/KPMHelpers.h" + +#include "utils/System.h" + +#include +#include +#include +#include +#include +#include + +#include + +/** @brief Determine if the given partition is of type Zfs + * + * Returns true if @p partition is of type Zfs + * + */ +static bool +isZfs( Partition* partition ) +{ + return partition->fileSystem().type() == FileSystem::Type::Zfs; +} + +/** @brief Remove the given partition manually + * + * Uses sfdisk to remove @p partition. This should only be used in cases + * where using kpmcore to remove the partition would not be appropriate + * + */ +static Calamares::JobResult +removePartition( Partition* partition ) +{ + auto r = Calamares::System::instance()->runCommand( + { "sfdisk", "--delete", "--force", partition->devicePath(), QString::number( partition->number() ) }, + std::chrono::seconds( 5 ) ); + if ( r.getExitCode() != 0 || r.getOutput().contains( "failed" ) ) + { + return Calamares::JobResult::error( + QCoreApplication::translate( DeletePartitionJob::staticMetaObject.className(), "Deletion Failed" ), + QCoreApplication::translate( DeletePartitionJob::staticMetaObject.className(), + "Failed to delete the partition with output: " ) + + r.getOutput() ); + } + else + { + return Calamares::JobResult::ok(); + } +} + +DeletePartitionJob::DeletePartitionJob( Device* device, Partition* partition ) + : PartitionJob( partition ) + , m_device( device ) +{ +} + +QString +DeletePartitionJob::prettyName() const +{ + return tr( "Deleting partition %1…", "@status" ).arg( m_partition->partitionPath() ); +} + +QString +DeletePartitionJob::prettyDescription() const +{ + return tr( "Deleting partition %1…", "@status" ).arg( m_partition->partitionPath() ); +} + +QString +DeletePartitionJob::prettyStatusMessage() const +{ + return tr( "Deleting partition %1…", "@status" ).arg( m_partition->partitionPath() ); +} + +Calamares::JobResult +DeletePartitionJob::exec() +{ + // The current implementation of remove() for zfs in kpmcore trys to destroy the zpool by label + // This isn't what we want here so we delete the partition instead. + if ( isZfs( m_partition ) ) + { + return removePartition( m_partition ); + } + + return KPMHelpers::execute( DeleteOperation( *m_device, m_partition ), + tr( "The installer failed to delete partition %1." ).arg( m_partition->devicePath() ) ); +} + +void +DeletePartitionJob::updatePreview() +{ + m_partition->parent()->remove( m_partition ); + m_device->partitionTable()->updateUnallocated( *m_device ); + + // Copied from PM DeleteOperation::checkAdjustLogicalNumbers(): + // + // If the deleted partition is a logical one, we need to adjust the numbers + // of the other logical partitions in the extended one, if there are any, + // because the OS will do that, too: Logicals must be numbered without gaps, + // i.e., a numbering like sda5, sda6, sda8 (after sda7 is deleted) will + // become sda5, sda6, sda7 + Partition* parentPartition = dynamic_cast< Partition* >( m_partition->parent() ); + if ( parentPartition && parentPartition->roles().has( PartitionRole::Extended ) ) + { + parentPartition->adjustLogicalNumbers( m_partition->number(), -1 ); + } +} diff --git a/calamares/src/modules/partition/jobs/DeletePartitionJob.h b/calamares/src/modules/partition/jobs/DeletePartitionJob.h new file mode 100644 index 0000000..6d5ff13 --- /dev/null +++ b/calamares/src/modules/partition/jobs/DeletePartitionJob.h @@ -0,0 +1,44 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef DELETEPARTITIONJOB_H +#define DELETEPARTITIONJOB_H + +#include "PartitionJob.h" + +class Device; +class Partition; +class FileSystem; + +/** + * Deletes an existing partition. + * + * This is only used for partitions which already existed before the installer + * was started: partitions created within the installer and then removed are + * simply forgotten. + */ +class DeletePartitionJob : public PartitionJob +{ + Q_OBJECT +public: + DeletePartitionJob( Device* device, Partition* partition ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + void updatePreview(); + Device* device() const { return m_device; } + +private: + Device* m_device; +}; + +#endif /* DELETEPARTITIONJOB_H */ diff --git a/calamares/src/modules/partition/jobs/FillGlobalStorageJob.cpp b/calamares/src/modules/partition/jobs/FillGlobalStorageJob.cpp new file mode 100644 index 0000000..357c1e2 --- /dev/null +++ b/calamares/src/modules/partition/jobs/FillGlobalStorageJob.cpp @@ -0,0 +1,400 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 2019-2020, Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "FillGlobalStorageJob.h" + +#include "compat/Variant.h" +#include "core/KPMHelpers.h" +#include "core/PartitionInfo.h" + +#include "Branding.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "partition/FileSystem.h" +#include "partition/Global.h" +#include "partition/PartitionIterator.h" +#include "utils/Logger.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using Calamares::Partition::PartitionIterator; +using Calamares::Partition::untranslatedFS; +using Calamares::Partition::userVisibleFS; + +typedef QHash< QString, QString > UuidForPartitionHash; + +static UuidForPartitionHash +findPartitionUuids( QList< Device* > devices ) +{ + UuidForPartitionHash hash; + foreach ( Device* device, devices ) + { + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + Partition* p = *it; + QString path = p->partitionPath(); + QString uuid = p->fileSystem().readUUID( p->partitionPath() ); + hash.insert( path, uuid ); + } + } + + if ( hash.isEmpty() ) + { + cDebug() << "No UUIDs found for existing partitions."; + } + return hash; +} + + +static QString +getLuksUuid( const QString& path ) +{ + QProcess process; + process.setProgram( "cryptsetup" ); + process.setArguments( { "luksUUID", path } ); + process.start(); + process.waitForFinished(); + if ( process.exitStatus() != QProcess::NormalExit || process.exitCode() ) + { + return QString(); + } + QString uuid = QString::fromLocal8Bit( process.readAllStandardOutput() ).trimmed(); + return uuid; +} + + +static QVariant +mapForPartition( Partition* partition, const QString& uuid ) +{ + QVariantMap map; + map[ "device" ] = partition->partitionPath(); + map[ "partlabel" ] = partition->label(); + map[ "partuuid" ] = partition->uuid(); + map[ "mountPoint" ] = PartitionInfo::mountPoint( partition ); + map[ "fsName" ] = userVisibleFS( partition->fileSystem() ); + map[ "fs" ] = untranslatedFS( partition->fileSystem() ); + map[ "parttype" ] = partition->type(); + map[ "partattrs" ] = partition->attributes(); + map[ "features" ] = partition->fileSystem().features(); + + if ( partition->fileSystem().type() == FileSystem::Luks + && dynamic_cast< FS::luks& >( partition->fileSystem() ).innerFS() ) + { + map[ "fs" ] = untranslatedFS( dynamic_cast< FS::luks& >( partition->fileSystem() ).innerFS() ); + } + if ( partition->fileSystem().type() == FileSystem::Luks2 + && dynamic_cast< FS::luks2& >( partition->fileSystem() ).innerFS() ) + { + map[ "fs" ] = untranslatedFS( dynamic_cast< FS::luks2& >( partition->fileSystem() ).innerFS() ); + } + + map[ "uuid" ] = uuid; + map[ "claimed" ] = PartitionInfo::format( partition ); // If we formatted it, it's ours + + // Debugging for inside the loop in createPartitionList(), + // so indent a bit + Logger::CDebug deb; + using TR = Logger::DebugRow< const char* const, const QString >; + // clang-format off + deb << Logger::SubEntry << "mapping for" << partition->partitionPath() << partition->deviceNode() + << TR( "partlabel", map[ "partlabel" ].toString() ) + << TR( "partition-uuid (partuuid)", Logger::RedactedName( "PartUUID", map[ "partuuid" ].toString() ) ) + << TR( "parttype", map[ "parttype" ].toString() ) + << TR( "partattrs", map[ "partattrs" ].toString() ) + << TR( "mountPoint:", PartitionInfo::mountPoint( partition ) ) + << TR( "fs:", map[ "fs" ].toString() ) + << TR( "fsName", map[ "fsName" ].toString() ) + << TR( "filesystem-uuid (uuid)", Logger::RedactedName( "FSUUID", uuid ) ) + << TR( "claimed", map[ "claimed" ].toString() ); + // clang-format on + if ( partition->roles().has( PartitionRole::Luks ) ) + { + const FileSystem& fsRef = partition->fileSystem(); + const FS::luks* luksFs = dynamic_cast< const FS::luks* >( &fsRef ); + if ( luksFs ) + { + map[ "luksMapperName" ] = luksFs->mapperName().split( "/" ).last(); + map[ "luksUuid" ] = getLuksUuid( partition->partitionPath() ); + map[ "luksPassphrase" ] = luksFs->passphrase(); + deb << TR( "luksMapperName:", map[ "luksMapperName" ].toString() ); + } + } + + return map; +} + +static QString +prettyFileSystemFeatures( const QVariantMap& features ) +{ + QStringList list; + for ( const auto& key : features.keys() ) + { + const auto& value = features.value( key ); + if ( Calamares::typeOf( value ) == Calamares::BoolVariantType ) + { + if ( value.toBool() ) + { + list += key; + } + else + { + list += QString( "not " ) + key; + } + } + else + { + list += key + QString( "=" ) + value.toString(); + } + } + + return list.join( QStringLiteral( ", " ) ); +} + +FillGlobalStorageJob::FillGlobalStorageJob( const Config*, QList< Device* > devices, const QString& bootLoaderPath ) + : m_devices( devices ) + , m_bootLoaderPath( bootLoaderPath ) +{ +} + +QString +FillGlobalStorageJob::prettyName() const +{ + return tr( "Set partition information", "@title" ); +} + + +QString +FillGlobalStorageJob::prettyDescription() const +{ + QStringList lines; + + const auto partitionList = createPartitionList(); + for ( const QVariant& partitionItem : partitionList ) + { + if ( Calamares::typeOf( partitionItem ) == Calamares::MapVariantType ) + { + QVariantMap partitionMap = partitionItem.toMap(); + QString path = partitionMap.value( "device" ).toString(); + QString mountPoint = partitionMap.value( "mountPoint" ).toString(); + QString fsType = partitionMap.value( "fs" ).toString(); + QString features = prettyFileSystemFeatures( partitionMap.value( "features" ).toMap() ); + if ( mountPoint.isEmpty() || fsType.isEmpty() || fsType == QString( "unformatted" ) ) + { + continue; + } + if ( path.isEmpty() ) + { + if ( mountPoint == "/" ) + { + if ( !features.isEmpty() ) + { + lines.append( tr( "Install %1 on new %2 system partition " + "with features %3", + "@info" ) + .arg( Calamares::Branding::instance()->shortProductName() ) + .arg( fsType ) + .arg( features ) ); + } + else + { + lines.append( tr( "Install %1 on new %2 system partition", "@info" ) + .arg( Calamares::Branding::instance()->shortProductName() ) + .arg( fsType ) ); + } + } + else + { + if ( !features.isEmpty() ) + { + lines.append( tr( "Set up new %2 partition with mount point " + "%1 and features %3", + "@info" ) + .arg( mountPoint ) + .arg( fsType ) + .arg( features ) ); + } + else + { + lines.append( tr( "Set up new %2 partition with mount point " + "%1%3", + "@info" ) + .arg( mountPoint ) + .arg( fsType ) + .arg( features ) ); + } + } + } + else + { + if ( mountPoint == "/" ) + { + if ( !features.isEmpty() ) + { + lines.append( tr( "Install %2 on %3 system partition %1" + " with features %4", + "@info" ) + .arg( path ) + .arg( Calamares::Branding::instance()->shortProductName() ) + .arg( fsType ) + .arg( features ) ); + } + else + { + lines.append( tr( "Install %2 on %3 system partition %1", "@info" ) + .arg( path ) + .arg( Calamares::Branding::instance()->shortProductName() ) + .arg( fsType ) ); + } + } + else + { + if ( !features.isEmpty() ) + { + lines.append( tr( "Set up %3 partition %1 with mount point " + "%2 and features %4", + "@info" ) + .arg( path ) + .arg( mountPoint ) + .arg( fsType ) + .arg( features ) ); + } + else + { + lines.append( tr( "Set up %3 partition %1 with mount point " + "%2%4…", + "@info" ) + .arg( path ) + .arg( mountPoint ) + .arg( fsType ) + .arg( QString() ) ); + } + } + } + } + } + + QVariant bootloaderMap = createBootLoaderMap(); + if ( !m_bootLoaderPath.isEmpty() ) + { + lines.append( tr( "Install boot loader on %1…", "@info" ).arg( m_bootLoaderPath ) ); + } + return lines.join( "
    " ); +} + + +QString +FillGlobalStorageJob::prettyStatusMessage() const +{ + return tr( "Setting up mount points…", "@status" ); +} + + +/** @brief note which FS'ses are in use in GS + * + * .. mark as "1" if it's on the system, somewhere + * .. mark as "2" if it's one of the claimed / in-use FSses + * + * Stores a GS key called "filesystem_use" with this mapping. + * @see Calamares::Partition::useFilesystemGS() + */ +static void +storeFSUse( Calamares::GlobalStorage* storage, const QVariantList& partitions ) +{ + if ( storage ) + { + Calamares::Partition::clearFilesystemGS( storage ); + for ( const auto& p : partitions ) + { + const auto pmap = p.toMap(); + + QString fs = pmap.value( "fs" ).toString(); + + if ( fs.isEmpty() ) + { + continue; + } + + Calamares::Partition::useFilesystemGS( storage, fs, true ); + } + } +} + +Calamares::JobResult +FillGlobalStorageJob::exec() +{ + Calamares::GlobalStorage* storage = Calamares::JobQueue::instance()->globalStorage(); + const auto partitions = createPartitionList(); + cDebug() << "Saving partition information map to GlobalStorage[\"partitions\"]"; + storage->insert( "partitions", partitions ); + storeFSUse( storage, partitions ); + + if ( !m_bootLoaderPath.isEmpty() ) + { + QVariant var = createBootLoaderMap(); + if ( !var.isValid() ) + { + cDebug() << "Failed to find path for boot loader"; + } + cDebug() << "FillGlobalStorageJob writing bootLoader path:" << var; + storage->insert( "bootLoader", var ); + } + else + { + cDebug() << "FillGlobalStorageJob writing empty bootLoader value"; + storage->insert( "bootLoader", QVariant() ); + } + return Calamares::JobResult::ok(); +} + +QVariantList +FillGlobalStorageJob::createPartitionList() const +{ + UuidForPartitionHash hash = findPartitionUuids( m_devices ); + QVariantList lst; + cDebug() << "Building partition information map"; + for ( auto device : m_devices ) + { + cDebug() << Logger::SubEntry << "partitions on" << device->deviceNode(); + for ( auto it = PartitionIterator::begin( device ); it != PartitionIterator::end( device ); ++it ) + { + // Debug-logging is done when creating the map + lst << mapForPartition( *it, hash.value( ( *it )->partitionPath() ) ); + } + } + return lst; +} + +QVariant +FillGlobalStorageJob::createBootLoaderMap() const +{ + QVariantMap map; + QString path = m_bootLoaderPath; + if ( !path.startsWith( "/dev/" ) ) + { + Partition* partition = KPMHelpers::findPartitionByMountPoint( m_devices, path ); + if ( !partition ) + { + return QVariant(); + } + path = partition->partitionPath(); + } + map[ "installPath" ] = path; + return map; +} diff --git a/calamares/src/modules/partition/jobs/FillGlobalStorageJob.h b/calamares/src/modules/partition/jobs/FillGlobalStorageJob.h new file mode 100644 index 0000000..039fb18 --- /dev/null +++ b/calamares/src/modules/partition/jobs/FillGlobalStorageJob.h @@ -0,0 +1,49 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef FILLGLOBALSTORAGEJOB_H +#define FILLGLOBALSTORAGEJOB_H + +#include "Job.h" + +#include +#include + +class Config; +class Device; +class Partition; + +/** + * This job does not touch devices. It inserts in GlobalStorage the + * partition-related keys (see hacking/GlobalStorage.md) + * + * Inserting the keys after partitioning makes it possible to access + * information such as the partition path or the UUID. + */ +class FillGlobalStorageJob : public Calamares::Job +{ + Q_OBJECT +public: + FillGlobalStorageJob( const Config* config, QList< Device* > devices, const QString& bootLoaderPath ); + + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + QList< Device* > m_devices; + QString m_bootLoaderPath; + + QVariantList createPartitionList() const; + QVariant createBootLoaderMap() const; +}; + +#endif /* FILLGLOBALSTORAGEJOB_H */ diff --git a/calamares/src/modules/partition/jobs/FormatPartitionJob.cpp b/calamares/src/modules/partition/jobs/FormatPartitionJob.cpp new file mode 100644 index 0000000..e373cec --- /dev/null +++ b/calamares/src/modules/partition/jobs/FormatPartitionJob.cpp @@ -0,0 +1,85 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "FormatPartitionJob.h" + +#include "core/KPMHelpers.h" + +#include "partition/FileSystem.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include +#include +#include +#include +#include +#include + +using Calamares::Partition::untranslatedFS; +using Calamares::Partition::userVisibleFS; + +FormatPartitionJob::FormatPartitionJob( Device* device, Partition* partition ) + : PartitionJob( partition ) + , m_device( device ) +{ +} + +QString +FormatPartitionJob::prettyName() const +{ + return tr( "Format partition %1 (file system: %2, size: %3 MiB) on %4", "@title" ) + .arg( m_partition->partitionPath() ) + .arg( userVisibleFS( m_partition->fileSystem() ) ) + .arg( m_partition->capacity() / 1024 / 1024 ) + .arg( m_device->name() ); +} + +QString +FormatPartitionJob::prettyDescription() const +{ + return tr( "Format %3MiB partition %1 with " + "file system %2", + "@info" ) + .arg( m_partition->partitionPath() ) + .arg( userVisibleFS( m_partition->fileSystem() ) ) + .arg( m_partition->capacity() / 1024 / 1024 ); +} + +QString +FormatPartitionJob::prettyStatusMessage() const +{ + QString partitionLabel = m_partition->label().isEmpty() + ? m_partition->partitionPath() + : tr( "%1 (%2)", "partition label %1 (device path %2)" ) + .arg( m_partition->label(), m_partition->partitionPath() ); + return tr( "Formatting partition %1 with file system %2…", "@status" ) + .arg( partitionLabel, userVisibleFS( m_partition->fileSystem() ) ); +} + +Calamares::JobResult +FormatPartitionJob::exec() +{ + const auto fsType = m_partition->fileSystem().type(); + auto r = KPMHelpers::execute( CreateFileSystemOperation( *m_device, *m_partition, fsType ), + tr( "The installer failed to format partition %1 on disk '%2'." ) + .arg( m_partition->partitionPath(), m_device->name() ) ); + if ( fsType == FileSystem::Xfs && r.succeeded() ) + { + // We are going to try to set modern timestamps for the filesystem, + // (ignoring whether this succeeds). Requires a sufficiently-new + // xfs_admin and xfs_repair and might be made obsolete by newer + // kpmcore releases. + Calamares::System::runCommand( { "xfs_admin", "-O", "bigtime=1", m_partition->partitionPath() }, + std::chrono::seconds( 60 ) ); + } + return r; +} diff --git a/calamares/src/modules/partition/jobs/FormatPartitionJob.h b/calamares/src/modules/partition/jobs/FormatPartitionJob.h new file mode 100644 index 0000000..38b2ee1 --- /dev/null +++ b/calamares/src/modules/partition/jobs/FormatPartitionJob.h @@ -0,0 +1,42 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef FORMATPARTITIONJOB_H +#define FORMATPARTITIONJOB_H + +#include "PartitionJob.h" + +class Device; +class Partition; +class FileSystem; + +/** + * This job formats an existing partition. + * + * It is only used for existing partitions: newly created partitions are + * formatted by the CreatePartitionJob. + */ +class FormatPartitionJob : public PartitionJob +{ + Q_OBJECT +public: + FormatPartitionJob( Device* device, Partition* partition ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + Device* device() const { return m_device; } + +private: + Device* m_device; +}; + +#endif /* FORMATPARTITIONJOB_H */ diff --git a/calamares/src/modules/partition/jobs/PartitionJob.cpp b/calamares/src/modules/partition/jobs/PartitionJob.cpp new file mode 100644 index 0000000..ca9b009 --- /dev/null +++ b/calamares/src/modules/partition/jobs/PartitionJob.cpp @@ -0,0 +1,29 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionJob.h" + +PartitionJob::PartitionJob( Partition* partition ) + : m_partition( partition ) +{ +} + +void +PartitionJob::iprogress( int percent ) +{ + if ( percent < 0 ) + { + percent = 0; + } + if ( percent > 100 ) + { + percent = 100; + } + Q_EMIT progress( qreal( percent / 100.0 ) ); +} diff --git a/calamares/src/modules/partition/jobs/PartitionJob.h b/calamares/src/modules/partition/jobs/PartitionJob.h new file mode 100644 index 0000000..f808a79 --- /dev/null +++ b/calamares/src/modules/partition/jobs/PartitionJob.h @@ -0,0 +1,43 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONJOB_H +#define PARTITIONJOB_H + +#include "Job.h" +#include "partition/KPMManager.h" + +class Partition; + +/** + * Base class for jobs which affect a partition and which use KPMCore. + */ +class PartitionJob : public Calamares::Job +{ + Q_OBJECT +public: + PartitionJob( Partition* partition ); + + Partition* partition() const { return m_partition; } + +public slots: + /** @brief Translate from KPMCore to Calamares progress. + * + * KPMCore presents progress as an integer percent from 0 .. 100, + * while Calamares uses a qreal from 0 .. 1.00 . + */ + void iprogress( int percent ); + +protected: + Calamares::Partition::KPMManager m_kpmcore; + Partition* m_partition; +}; + +#endif /* PARTITIONJOB_H */ diff --git a/calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.cpp b/calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.cpp new file mode 100644 index 0000000..b139d93 --- /dev/null +++ b/calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.cpp @@ -0,0 +1,47 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "RemoveVolumeGroupJob.h" + +#include "core/KPMHelpers.h" + +#include +#include +#include + +RemoveVolumeGroupJob::RemoveVolumeGroupJob( Device*, LvmDevice* device ) + : m_device( device ) +{ +} + +QString +RemoveVolumeGroupJob::prettyName() const +{ + return tr( "Removing Volume Group named %1…", "@status" ).arg( m_device->name() ); +} + +QString +RemoveVolumeGroupJob::prettyDescription() const +{ + return tr( "Removing Volume Group named %1…", "@status" ).arg( m_device->name() ); +} + +QString +RemoveVolumeGroupJob::prettyStatusMessage() const +{ + return tr( "Removing Volume Group named %1…", "@status" ).arg( m_device->name() ); +} + +Calamares::JobResult +RemoveVolumeGroupJob::exec() +{ + return KPMHelpers::execute( + RemoveVolumeGroupOperation( *m_device ), + tr( "The installer failed to remove a volume group named '%1'." ).arg( m_device->name() ) ); +} diff --git a/calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.h b/calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.h new file mode 100644 index 0000000..a1f586f --- /dev/null +++ b/calamares/src/modules/partition/jobs/RemoveVolumeGroupJob.h @@ -0,0 +1,35 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef REMOVEVOLUMEGROUPJOB_H +#define REMOVEVOLUMEGROUPJOB_H + +#include "Job.h" +#include "partition/KPMManager.h" + +class Device; +class LvmDevice; + +class RemoveVolumeGroupJob : public Calamares::Job +{ + Q_OBJECT +public: + RemoveVolumeGroupJob( Device*, LvmDevice* device ); + + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + Calamares::Partition::KPMManager m_kpmcore; + LvmDevice* m_device; +}; + +#endif // REMOVEVOLUMEGROUPJOB_H diff --git a/calamares/src/modules/partition/jobs/ResizePartitionJob.cpp b/calamares/src/modules/partition/jobs/ResizePartitionJob.cpp new file mode 100644 index 0000000..3aec413 --- /dev/null +++ b/calamares/src/modules/partition/jobs/ResizePartitionJob.cpp @@ -0,0 +1,90 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Andrius Štikonas + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ResizePartitionJob.h" + +#include "core/KPMHelpers.h" + +#include "utils/Units.h" + +#include +#include +#include + +using Calamares::BytesToMiB; + +//- ResizePartitionJob --------------------------------------------------------- +ResizePartitionJob::ResizePartitionJob( Device* device, Partition* partition, qint64 firstSector, qint64 lastSector ) + : PartitionJob( partition ) + , m_device( device ) + , m_oldFirstSector( + partition->firstSector() ) // Keep a copy of old sectors because they will be overwritten in updatePreview() + , m_oldLastSector( partition->lastSector() ) + , m_newFirstSector( firstSector ) + , m_newLastSector( lastSector ) +{ +} + +QString +ResizePartitionJob::prettyName() const +{ + return tr( "Resize partition %1", "@title" ).arg( partition()->partitionPath() ); +} + +QString +ResizePartitionJob::prettyDescription() const +{ + return tr( "Resize %2MiB partition %1 to %3MiB", "@info" ) + .arg( partition()->partitionPath() ) + .arg( ( BytesToMiB( m_oldLastSector - m_oldFirstSector + 1 ) * partition()->sectorSize() ) ) + .arg( ( BytesToMiB( m_newLastSector - m_newFirstSector + 1 ) * partition()->sectorSize() ) ); +} + +QString +ResizePartitionJob::prettyStatusMessage() const +{ + return tr( "Resizing %2MiB partition %1 to %3MiB…", "@status" ) + .arg( partition()->partitionPath() ) + .arg( ( BytesToMiB( m_oldLastSector - m_oldFirstSector + 1 ) * partition()->sectorSize() ) ) + .arg( ( BytesToMiB( m_newLastSector - m_newFirstSector + 1 ) * partition()->sectorSize() ) ); +} + +Calamares::JobResult +ResizePartitionJob::exec() +{ + // Restore partition sectors that were modified for preview + m_partition->setFirstSector( m_oldFirstSector ); + m_partition->setLastSector( m_oldLastSector ); + + ResizeOperation op( *m_device, *m_partition, m_newFirstSector, m_newLastSector ); + connect( &op, &Operation::progress, this, &ResizePartitionJob::iprogress ); + return KPMHelpers::execute( op, + tr( "The installer failed to resize partition %1 on disk '%2'." ) + .arg( m_partition->partitionPath() ) + .arg( m_device->name() ) ); +} + +void +ResizePartitionJob::updatePreview() +{ + m_device->partitionTable()->removeUnallocated(); + m_partition->parent()->remove( m_partition ); + m_partition->setFirstSector( m_newFirstSector ); + m_partition->setLastSector( m_newLastSector ); + m_partition->parent()->insert( m_partition ); + m_device->partitionTable()->updateUnallocated( *m_device ); +} + +Device* +ResizePartitionJob::device() const +{ + return m_device; +} diff --git a/calamares/src/modules/partition/jobs/ResizePartitionJob.h b/calamares/src/modules/partition/jobs/ResizePartitionJob.h new file mode 100644 index 0000000..9e24b2d --- /dev/null +++ b/calamares/src/modules/partition/jobs/ResizePartitionJob.h @@ -0,0 +1,47 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef RESIZEPARTITIONJOB_H +#define RESIZEPARTITIONJOB_H + +#include "PartitionJob.h" + +class Device; +class Partition; +class FileSystem; + +/** + * This job resizes an existing partition. + * + * It can grow, shrink and/or move a partition while preserving its content. + */ +class ResizePartitionJob : public PartitionJob +{ + Q_OBJECT +public: + ResizePartitionJob( Device* device, Partition* partition, qint64 firstSector, qint64 lastSector ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + void updatePreview(); + + Device* device() const; + +private: + Device* m_device; + qint64 m_oldFirstSector; + qint64 m_oldLastSector; + qint64 m_newFirstSector; + qint64 m_newLastSector; +}; + +#endif /* RESIZEPARTITIONJOB_H */ diff --git a/calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.cpp b/calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.cpp new file mode 100644 index 0000000..a642711 --- /dev/null +++ b/calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.cpp @@ -0,0 +1,89 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ResizeVolumeGroupJob.h" + +#include "core/KPMHelpers.h" + +#include +#include +#include +#include + +ResizeVolumeGroupJob::ResizeVolumeGroupJob( Device*, LvmDevice* device, QVector< const Partition* >& partitionList ) + : m_device( device ) + , m_partitionList( partitionList ) +{ +} + +QString +ResizeVolumeGroupJob::prettyName() const +{ + return tr( "Resize volume group named %1 from %2 to %3", "@title" ) + .arg( m_device->name() ) + .arg( currentPartitions() ) + .arg( targetPartitions() ); +} + +QString +ResizeVolumeGroupJob::prettyDescription() const +{ + return tr( "Resize volume group named %1 from %2 to %3", + "@info" ) + .arg( m_device->name() ) + .arg( currentPartitions() ) + .arg( targetPartitions() ); +} + +QString +ResizeVolumeGroupJob::prettyStatusMessage() const +{ + return tr( "Resizing volume group named %1 from %2 to %3…", "@status" ) + .arg( m_device->name() ) + .arg( currentPartitions() ) + .arg( targetPartitions() ); +} + +Calamares::JobResult +ResizeVolumeGroupJob::exec() +{ + return KPMHelpers::execute( + ResizeVolumeGroupOperation( *m_device, m_partitionList ), + tr( "The installer failed to resize a volume group named '%1'." ).arg( m_device->name() ) ); +} + +QString +ResizeVolumeGroupJob::currentPartitions() const +{ + QString result; + + for ( const Partition* p : m_device->physicalVolumes() ) + { + result += p->deviceNode() + ", "; + } + + result.chop( 2 ); + + return result; +} + +QString +ResizeVolumeGroupJob::targetPartitions() const +{ + QString result; + + for ( const Partition* p : m_partitionList ) + { + result += p->deviceNode() + ", "; + } + + result.chop( 2 ); + + return result; +} diff --git a/calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.h b/calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.h new file mode 100644 index 0000000..07765cf --- /dev/null +++ b/calamares/src/modules/partition/jobs/ResizeVolumeGroupJob.h @@ -0,0 +1,43 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Caio Jordão Carvalho + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef RESIZEVOLUMEGROUPJOB_H +#define RESIZEVOLUMEGROUPJOB_H + +#include "Job.h" +#include "partition/KPMManager.h" + +#include + +class Device; +class LvmDevice; +class Partition; + +class ResizeVolumeGroupJob : public Calamares::Job +{ + Q_OBJECT +public: + ResizeVolumeGroupJob( Device*, LvmDevice* device, QVector< const Partition* >& partitionList ); + + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + QString currentPartitions() const; + QString targetPartitions() const; + +private: + Calamares::Partition::KPMManager m_kpmcore; + LvmDevice* m_device; + QVector< const Partition* > m_partitionList; +}; + +#endif // RESIZEVOLUMEGROUPJOB_H diff --git a/calamares/src/modules/partition/jobs/SetPartitionFlagsJob.cpp b/calamares/src/modules/partition/jobs/SetPartitionFlagsJob.cpp new file mode 100644 index 0000000..1eafc63 --- /dev/null +++ b/calamares/src/modules/partition/jobs/SetPartitionFlagsJob.cpp @@ -0,0 +1,150 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2008 2010, Volker Lanz + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Based on the SetPartFlagsJob class from KDE Partition Manager + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SetPartitionFlagsJob.h" + +#include "core/KPMHelpers.h" + +#include "partition/FileSystem.h" +#include "utils/Logger.h" +#include "utils/Units.h" + +#include +#include +#include +#include +#include + +using Calamares::BytesToMiB; +using Calamares::Partition::untranslatedFS; +using Calamares::Partition::userVisibleFS; + +SetPartFlagsJob::SetPartFlagsJob( Device* device, Partition* partition, PartitionTable::Flags flags ) + : PartitionJob( partition ) + , m_device( device ) + , m_flags( flags ) +{ +} + +QString +SetPartFlagsJob::prettyName() const +{ + if ( !partition()->partitionPath().isEmpty() ) + { + return tr( "Set flags on partition %1", "@title" ).arg( partition()->partitionPath() ); + } + + QString fsNameForUser = userVisibleFS( partition()->fileSystem() ); + if ( !fsNameForUser.isEmpty() ) + { + return tr( "Set flags on %1MiB %2 partition", "@title" ) + .arg( BytesToMiB( partition()->capacity() ) ) + .arg( fsNameForUser ); + } + return tr( "Set flags on new partition", "@title" ); +} + +QString +SetPartFlagsJob::prettyDescription() const +{ + QStringList flagsList = PartitionTable::flagNames( m_flags ); + if ( flagsList.count() == 0 ) + { + if ( !partition()->partitionPath().isEmpty() ) + { + return tr( "Clear flags on partition %1", "@info" ).arg( partition()->partitionPath() ); + } + + QString fsNameForUser = userVisibleFS( partition()->fileSystem() ); + if ( !fsNameForUser.isEmpty() ) + { + return tr( "Clear flags on %1MiB %2 partition", "@info" ) + .arg( BytesToMiB( partition()->capacity() ) ) + .arg( fsNameForUser ); + } + return tr( "Clear flags on new partition", "@info" ); + } + + if ( !partition()->partitionPath().isEmpty() ) + { + return tr( "Set flags on partition %1 to %2", "@info" ) + .arg( partition()->partitionPath() ) + .arg( flagsList.join( ", " ) ); + } + + QString fsNameForUser = userVisibleFS( partition()->fileSystem() ); + if ( !fsNameForUser.isEmpty() ) + { + return tr( "Set flags on %1MiB %2 partition to %3", "@info" ) + .arg( BytesToMiB( partition()->capacity() ) ) + .arg( fsNameForUser ) + .arg( flagsList.join( ", " ) ); + } + + return tr( "Set flags on new partition to %1", "@info" ).arg( flagsList.join( ", " ) ); +} + +QString +SetPartFlagsJob::prettyStatusMessage() const +{ + QStringList flagsList = PartitionTable::flagNames( m_flags ); + if ( flagsList.count() == 0 ) + { + if ( !partition()->partitionPath().isEmpty() ) + { + return tr( "Clearing flags on partition %1…", "@status" ) + .arg( partition()->partitionPath() ); + } + + QString fsNameForUser = userVisibleFS( partition()->fileSystem() ); + if ( !fsNameForUser.isEmpty() ) + { + return tr( "Clearing flags on %1MiB %2 partition…", "@status" ) + .arg( BytesToMiB( partition()->capacity() ) ) + .arg( fsNameForUser ); + } + + return tr( "Clearing flags on new partition…", "@status" ); + } + + if ( !partition()->partitionPath().isEmpty() ) + { + return tr( "Setting flags %2 on partition %1…", "@status" ) + .arg( partition()->partitionPath() ) + .arg( flagsList.join( ", " ) ); + } + + QString fsNameForUser = userVisibleFS( partition()->fileSystem() ); + if ( !fsNameForUser.isEmpty() ) + { + return tr( "Setting flags %3 on %1MiB %2 partition…", "@status" ) + .arg( BytesToMiB( partition()->capacity() ) ) + .arg( fsNameForUser ) + .arg( flagsList.join( ", " ) ); + } + + return tr( "Setting flags %1 on new partition…", "@status" ).arg( flagsList.join( ", " ) ); +} + +Calamares::JobResult +SetPartFlagsJob::exec() +{ + QStringList flagsList = PartitionTable::flagNames( m_flags ); + cDebug() << "Setting flags on" << m_device->deviceNode() << "partition" << partition()->deviceNode() + << Logger::DebugList( flagsList ); + + SetPartFlagsOperation op( *m_device, *partition(), m_flags ); + connect( &op, &Operation::progress, this, &SetPartFlagsJob::iprogress ); + return KPMHelpers::execute( + op, tr( "The installer failed to set flags on partition %1." ).arg( m_partition->partitionPath() ) ); +} diff --git a/calamares/src/modules/partition/jobs/SetPartitionFlagsJob.h b/calamares/src/modules/partition/jobs/SetPartitionFlagsJob.h new file mode 100644 index 0000000..eb6d958 --- /dev/null +++ b/calamares/src/modules/partition/jobs/SetPartitionFlagsJob.h @@ -0,0 +1,43 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2016 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Based on the SetPartFlagsJob class from KDE Partition Manager, + * SPDX-FileCopyrightText: 2008 2010, Volker Lanz + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SETPARTITIONFLAGSJOB_H +#define SETPARTITIONFLAGSJOB_H + +#include "PartitionJob.h" + +#include + +class Device; +class Partition; + +/** + * This job changes the flags on an existing partition. + */ +class SetPartFlagsJob : public PartitionJob +{ + Q_OBJECT +public: + SetPartFlagsJob( Device* device, Partition* partition, PartitionTable::Flags flags ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + + Device* device() const; + +private: + Device* m_device; + PartitionTable::Flags m_flags; +}; + +#endif // SETPARTITIONFLAGSJOB_H diff --git a/calamares/src/modules/partition/partition.conf b/calamares/src/modules/partition/partition.conf new file mode 100644 index 0000000..e77c5d0 --- /dev/null +++ b/calamares/src/modules/partition/partition.conf @@ -0,0 +1,392 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# + +# Options for EFI system partition. +# +# - *mountPoint* +# This setting specifies the mount point of the EFI system partition. Some +# distributions (Fedora, Debian, Manjaro, etc.) use /boot/efi, others (KaOS, +# etc.) use just /boot. +# +# Defaults to "/boot/efi", may be empty (but weird effects ensue) +# - *recommendedSize* +# This optional setting specifies the size of the EFI system partition. +# If nothing is specified, the default size of 300MiB will be used. +# When writing quantities here, M is treated as MiB, and if you really +# want one-million (10^6) bytes, use MB. +# - *minimumSize* +# This optional setting specifies the absolute minimum size of the EFI +# system partition. If nothing is specified, the *recommendedSize* +# is used instead. +# - *label* +# This optional setting specifies the name of the EFI system partition (see +# PARTLABEL; gpt only; requires KPMCore >= 4.2.0). +# If nothing is specified, the partition name is left unset. +# +# Going below the *recommended* size is allowed, but the user will +# get a warning that it might not work. Going below the *minimum* +# size is not allowed and the user will be told it will not work. +# +# Both quantities must be at least 32MiB, this is enforced by the EFI +# spec. If minimum is not specified, it defaults to the recommended +# size. Distros that allow more user latitude can set the minimum lower. +efi: + mountPoint: "/boot/efi" + recommendedSize: 300MiB + minimumSize: 32MiB + label: "EFI" + +# Deprecated alias of efi.mountPoint +# efiSystemPartition: "/boot/efi" + +# Deprecated alias of efi.recommendedSize +# efiSystemPartitionSize: 300MiB + +# Deprecated alias of efi.label +# efiSystemPartitionName: EFI + +# In autogenerated partitioning, allow the user to select a swap size? +# If there is exactly one choice, no UI is presented, and the user +# cannot make a choice -- this setting is used. If there is more than +# one choice, a UI is presented. +# +# Legacy settings *neverCreateSwap* and *ensureSuspendToDisk* correspond +# to values of *userSwapChoices* as follows: +# - *neverCreateSwap* is true, means [none] +# - *neverCreateSwap* is false, *ensureSuspendToDisk* is false, [small] +# - *neverCreateSwap* is false, *ensureSuspendToDisk* is true, [suspend] +# +# Autogenerated swap sizes are as follows: +# - *suspend*: Swap is always at least total memory size, +# and up to 4GiB RAM follows the rule-of-thumb 2 * memory; +# from 4GiB to 8 GiB it stays steady at 8GiB, and over 8 GiB memory +# swap is the size of main memory. +# - *small*: Follows the rules above, but Swap is at +# most 8GiB, and no more than 10% of available disk. +# In both cases, a fudge factor (usually 10% extra) is applied so that there +# is some space for administrative overhead (e.g. 8 GiB swap will allocate +# 8.8GiB on disk in the end). +# +# If *file* is enabled here, make sure to have the *fstab* module +# as well (later in the exec phase) so that the swap file is +# actually created. +userSwapChoices: + - none # Create no swap, use no swap + - small # Up to 4GB + - suspend # At least main memory size + # - reuse # Re-use existing swap, but don't create any (unsupported right now) + - file # To swap file instead of partition + +# This optional setting specifies the name of the swap partition (see +# PARTLABEL; gpt only; requires KPMCore >= 4.2.0). +# If nothing is specified, the partition name is left unset. +# swapPartitionName: swap + +# LEGACY SETTINGS (these will generate a warning) +# ensureSuspendToDisk: true +# neverCreateSwap: false + +# This setting specifies the LUKS generation (i.e LUKS1, LUKS2) used internally by +# cryptsetup when creating an encrypted partition. +# +# This option is set to luks1 by default, as grub doesn't support LUKS2 + Argon2id +# currently. On the other hand grub does support LUKS2 with PBKDF2 and could therefore be +# also set to luks2. Also there are some patches for grub and Argon2. +# See: https://aur.archlinux.org/packages/grub-improved-luks2-git +# +# Choices: luks1, luks2 (in addition, "luks" means "luks1") +# +# The default is luks1 +# +luksGeneration: luks1 + +# This setting determines if encryption should be allowed when using zfs. This +# setting has no effect unless zfs support is provided. +# +# This setting is to handle the fact that some bootloaders(such as grub) do not +# support zfs encryption. +# +# The default is true +# +# allowZfsEncryption: true + +# Correctly draw nested (e.g. logical) partitions as such. +drawNestedPartitions: false + +# Show/hide partition labels on manual partitioning page. +alwaysShowPartitionLabels: true + +# Allow manual partitioning. +# +# When set to false, this option hides the "Manual partitioning" button, +# limiting the user's choice to "Erase", "Replace" or "Alongside". +# This can be useful when using a custom partition layout we don't want +# the user to modify. +# +# If nothing is specified, manual partitioning is enabled. +#allowManualPartitioning: true + +# Show not encrypted boot partition warning. +# +# When set to false, this option does not show the +# "Boot partition not encrypted" warning when encrypting the +# root partition but not /boot partition. +# +# If nothing is specified, the warning is shown. +#showNotEncryptedBootMessage: true + +# Initial selection on the Choice page +# +# There are four radio buttons (in principle: erase, replace, alongside, manual), +# and you can pick which of them, if any, is initially selected. For most +# installers, "none" is the right choice: it makes the user pick something specific, +# rather than accidentally being able to click past an important choice (in particular, +# "erase" is a dangerous choice). +# +# The default is "none" +# +initialPartitioningChoice: none +# +# Similarly, some of the installation choices may offer a choice of swap; +# the available choices depend on *userSwapChoices*, above, and this +# setting can be used to pick a specific one. +# +# The default is "none" (no swap) if that is one of the enabled options, otherwise +# one of the items from the options. +initialSwapChoice: none + +# armInstall +# +# Leaves 16MB empty at the start of a drive when partitioning +# where usually the u-boot loader goes +# +# armInstall: false + +# Default partition table type, used when a "erase" disk is made. +# +# When erasing a disk, a new partition table is created on disk. +# In other cases, e.g. Replace and Alongside, as well as when using +# manual partitioning, this partition table exists already on disk +# and it is left unmodified. +# +# Possible values: gpt, msdos (or other names defined by KPMcore). +# Names are case-sensitive. +# +# If nothing is specified, Calamares defaults to "gpt" if system is +# efi or "msdos" otherwise. +# +# defaultPartitionTableType: msdos + +# Specify whether to create a partition table layout suitable for a hybrid +# (BIOS + EFI) bootloader installation. This will prepend both bios-boot and +# EFI system partitions to the partition layout, regardless of whether the +# booted system uses BIOS or EFI firmware. Defaults to false. +# +# createHybridBootloaderLayout: false + +# Requirement for partition table type +# +# Restrict the installation on disks that match the type of partition +# tables that are specified. +# +# Possible values: msdos, gpt (or other names defined by KPMcore). +# Names are case-sensitive. +# +# If nothing is specified, Calamares defaults to both "msdos" and "gpt". +# +# requiredPartitionTableType: gpt +# requiredPartitionTableType: +# - msdos +# - gpt + +# Default filesystem type, used when a "new" partition is made. +# +# When replacing a partition, the new filesystem type will be from the +# defaultFileSystemType value. In other cases, e.g. Erase and Alongside, +# as well as when using manual partitioning and creating a new +# partition, this filesystem type is pre-selected. Note that +# editing a partition in manual-creation mode will not automatically +# change the filesystem type to this default value -- it is not +# creating a new partition. +# +# Suggested values: ext2, ext3, ext4, reiser, xfs, jfs, btrfs +# If nothing is specified, Calamares defaults to "ext4". +# +# Names are case-sensitive and defined by KPMCore. +defaultFileSystemType: "ext4" + +# Selectable filesystem type, used when "erase" is done. +# +# When erasing the disk, the *defaultFileSystemType* is used (see +# above), but it is also possible to give users a choice: +# list suitable filesystems here. A drop-down is provided +# to pick which is the filesystems will be used. +# +# The value *defaultFileSystemType* is added to this list (with a warning) +# if not present; the default pick is the *defaultFileSystemType*. +# +# If not specified at all, uses *defaultFileSystemType* without a +# warning (this matches traditional no-choice-available behavior best). +# availableFileSystemTypes: ["ext4","f2fs"] + +# Per-directory filesystem restrictions. +# +# This optional setting specifies what filesystems the user can and cannot use +# for various directories and mountpoints when using manual partitioning. +# +# If nothing is specified, the only restriction enforced by default is that +# the EFI system partition must use the fat32 filesystem. +# +# Otherwise, the filesystem restrictions are defined as follow: +# +# directoryFilesystemRestrictions: +# - directory: "any" +# allowedFilesystemTypes: ["all"] +# - directory: "/" +# allowedFilesystemTypes: ["ext4","xfs","btrfs","jfs","f2fs"] +# - mountpoint: "efi" +# allowedFilesystemTypes: ["fat32"] +# onlyWhenMountpoint: true +# +# There can be any number of mountpoints listed, each entry having the +# following attributes: +# - mountpoint: mountpoint's full path +# or +# "any" to specify a global whitelist that applies to all +# mountpoints +# or +# "efi" to specify a whitelist specific to the EFI system +# partition, wherever that partition is located +# - allowedFilesystemTypes: the list of all filesystems valid for this +# mountpoint. If the list contains exactly one +# element, and that element is the special value +# "any", all filesystem types recognized by +# Calamares will be allowed. +# - onlyWhenMountpoint: Whether the restriction should apply only when the +# specified directory is a mountpoint. When set to +# true, Calamares will only enforce the listed +# restrictions when the user makes a separate partition +# for this directory and assigns the mountpoint +# accordingly. When set to false, Calamares will +# ensure this directory uses the specified filesystem +# even if the directory is part of a filesystem on a +# different mountpoint. Defaults to false. + +# The ClearMounts job unmounts / unmaps things before partitioning. +# Some special entries under /dev/mapper are excepted from this process. +# The example lists the three hard-coded exceptions which always apply +# (they don't need to be listed here). Add other names or wildcards (with +# a trailing '*') to this list if the live-ISO has additional mounts. +essentialMounts: [ "live-*", "control", "ventoy" ] + +# Show/hide LUKS related functionality in automated partitioning modes. +# Disable this if you choose not to deploy early unlocking support in GRUB2 +# and/or your distribution's initramfs solution. +# +# BIG FAT WARNING: +# +# This option is unsupported, as it cuts out a crucial security feature. +# Disabling LUKS and shipping Calamares without a correctly configured GRUB2 +# and initramfs is considered suboptimal use of the Calamares software. The +# Calamares team will not provide user support for any potential issue that +# may arise as a consequence of setting this option to false. +# It is strongly recommended that system integrators put in the work to support +# LUKS unlocking support in GRUB2 and initramfs/dracut/mkinitcpio/etc. +# For more information on setting up GRUB2 for Calamares with LUKS, see +# the Calamares website at https://calamares.io/docs/partitions/#luks . +# +# If nothing is specified, LUKS is enabled in automated modes. +#enableLuksAutomatedPartitioning: true + +# When enableLuksAutomatedPartitioning is true, this option will pre-check +# encryption checkbox. This option is only usefull to help people to not forget +# to cypher their disk when installing in enterprise (for example). +#preCheckEncryption: false + +# LVM support +# +# There is only one sub-key available, *enable* (defaults to true) +# which can be used to show (default) or hide the LVM buttons in the partitioning module. +lvm: + enable: true + +# Partition layout. +# +# This optional setting specifies a custom partition layout. +# +# If nothing is specified, the default partition layout is a single partition +# for root that uses 100% of the space and uses the filesystem defined by +# defaultFileSystemType. +# +# Note: the EFI system partition is prepended automatically to the layout if +# needed; the swap partition is appended to the layout if enabled (selections +# "small" or "suspend" in *userSwapChoices*). +# +# Otherwise, the partition layout is defined as follow: +# +# partitionLayout: +# - name: "rootfs" +# type: "4f68bce3-e8cd-4db1-96e7-fbcaf984b709" +# filesystem: "ext4" +# noEncrypt: false +# mountPoint: "/" +# size: 20% +# minSize: 500M +# maxSize: 10G +# attributes: 0xffff000000000003 +# - name: "home" +# type: "933ac7e1-2eb4-4f13-b844-0e14e2aef915" +# filesystem: "ext4" +# noEncrypt: false +# mountPoint: "/home" +# size: 3G +# minSize: 1.5G +# features: +# 64bit: false +# casefold: true +# - name: "data" +# filesystem: "fat32" +# mountPoint: "/data" +# features: +# sector-size: 4096 +# sectors-per-cluster: 128 +# size: 100% +# +# There can be any number of partitions, each entry having the following attributes: +# - name: filesystem label +# and +# partition name (gpt only; since KPMCore 4.2.0) +# - uuid: partition uuid (optional parameter; gpt only; requires KPMCore >= 4.2.0) +# - type: partition type (optional parameter; gpt only; requires KPMCore >= 4.2.0) +# - attributes: partition attributes (optional parameter; gpt only; requires KPMCore >= 4.2.0) +# - filesystem: filesystem type (optional parameter) +# - if not set at all, treat as "unformatted" +# - if "unformatted", no filesystem will be created +# - if "unknown" (or an unknown FS name, like "elephant") then the +# default filesystem type, or the user's choice, will be applied instead +# of "unknown" (e.g. the user might pick ext4, or xfs). +# - noEncrypt: whether this partition is exempt from encryption if enabled (optional parameter; default is false) +# - mountPoint: partition mount point (optional parameter; not mounted if unset) +# - size: partition size in bytes (append 'K', 'M' or 'G' for KiB, MiB or GiB) +# or +# % of the available drive space if a '%' is appended to the value +# - minSize: minimum partition size (optional parameter) +# - maxSize: maximum partition size (optional parameter) +# - features: filesystem features (optional parameter; requires KPMCore >= 4.2.0) +# name: boolean or integer or string + +# Checking for available storage +# +# This overlaps with the setting of the same name in the welcome module's +# requirements section. If nothing is set by the welcome module, this +# value is used instead. It is still a problem if there is no required +# size set at all, and the replace and resize options will not be offered +# if no required size is set. +# +# The value is in Gibibytes (GiB). +# +# BIG FAT WARNING: except for OEM-phase-0 use, you should be using +# the welcome module, **and** configure this value in +# `welcome.conf`, not here. +# requiredStorage: 3.5 diff --git a/calamares/src/modules/partition/partition.schema.yaml b/calamares/src/modules/partition/partition.schema.yaml new file mode 100644 index 0000000..07763ae --- /dev/null +++ b/calamares/src/modules/partition/partition.schema.yaml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-FileCopyrightText: 2023 Evan James +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/partition +additionalProperties: false +type: object +properties: + efiSystemPartition: { type: string } # Deprecated alias of efi.mountPoint + efiSystemPartitionSize: { type: string } # Deprecated alias of efi.recommendedSize + efiSystemPartitionName: { type: string } # Deprecated alias of efi.label + + efi: + type: object + properties: + recommendedSize: { type: string } + minimumSize: { type: string } + label: { type: string } + mountPoint: { type: string } + additionalProperties: false + + lvm: + type: object + properties: + enable: { type: boolean, default: true } + additionalProperties: false + + userSwapChoices: { type: array, items: { type: string, enum: [ none, reuse, small, suspend, file ] } } + # ensureSuspendToDisk: { type: boolean, default: true } # Legacy + # neverCreateSwap: { type: boolean, default: false } # Legacy + armInstall: { type: boolean, default: false } + + allowZfsEncryption: { type: boolean, default: true } + drawNestedPartitions: { type: boolean, default: false } + alwaysShowPartitionLabels: { type: boolean, default: true } + + defaultFileSystemType: { type: string } + availableFileSystemTypes: { type: array, items: { type: string } } + mountpointFilesystemRestrictions: { type: array } # TODO: specify items + + luksGeneration: { type: string, enum: [luks1, luks2] } # Also allows "luks" as alias of "luks1" + enableLuksAutomatedPartitioning: { type: boolean, default: false } + preCheckEncryption: { type: boolean, default: false } + essentialMounts: { type: array, items: { type: string } } # List of names under /dev/mapper not to close + + allowManualPartitioning: { type: boolean, default: true } + showNotEncryptedBootMessage: { type: boolean, default: true } + partitionLayout: { type: array } # TODO: specify items + initialPartitioningChoice: { type: string, enum: [ none, erase, replace, alongside, manual ] } + initialSwapChoice: { type: string, enum: [ none, small, suspend, reuse, file ] } + + requiredStorage: { type: number } +required: + - userSwapChoices diff --git a/calamares/src/modules/partition/tests/1a-legacy.conf b/calamares/src/modules/partition/tests/1a-legacy.conf new file mode 100644 index 0000000..f3435a2 --- /dev/null +++ b/calamares/src/modules/partition/tests/1a-legacy.conf @@ -0,0 +1,2 @@ +--- +efiSystemPartitionSize: 100MiB diff --git a/calamares/src/modules/partition/tests/1b-legacy.conf b/calamares/src/modules/partition/tests/1b-legacy.conf new file mode 100644 index 0000000..9792e81 --- /dev/null +++ b/calamares/src/modules/partition/tests/1b-legacy.conf @@ -0,0 +1,2 @@ +--- +efiSystemPartitionSize: 100MB diff --git a/calamares/src/modules/partition/tests/2a-legacy.conf b/calamares/src/modules/partition/tests/2a-legacy.conf new file mode 100644 index 0000000..47111a6 --- /dev/null +++ b/calamares/src/modules/partition/tests/2a-legacy.conf @@ -0,0 +1,9 @@ +--- +# Deprecated alias of efi.mountPoint +efiSystemPartition: "/boot/thisisatest" + +# Deprecated alias of efi.recommendedSize +efiSystemPartitionSize: 75MiB + +# Deprecated alias of efi.label +efiSystemPartitionName: testLabel diff --git a/calamares/src/modules/partition/tests/2b-modern.conf b/calamares/src/modules/partition/tests/2b-modern.conf new file mode 100644 index 0000000..5b6a6dd --- /dev/null +++ b/calamares/src/modules/partition/tests/2b-modern.conf @@ -0,0 +1,6 @@ +--- +efi: + mountPoint: "/boot/thisismodern" + recommendedSize: 80MiB + minimumSize: 65MiB + label: "UEFI" diff --git a/calamares/src/modules/partition/tests/2c-mixed.conf b/calamares/src/modules/partition/tests/2c-mixed.conf new file mode 100644 index 0000000..f472ebe --- /dev/null +++ b/calamares/src/modules/partition/tests/2c-mixed.conf @@ -0,0 +1,7 @@ +--- +efi: + mountPoint: "/boot/thisismixed" + minimumSize: 80MiB + +efiSystemPartitionSize: 175MiB +efiSystemPartitionName: legacy diff --git a/calamares/src/modules/partition/tests/2d-overlap.conf b/calamares/src/modules/partition/tests/2d-overlap.conf new file mode 100644 index 0000000..9ddfa56 --- /dev/null +++ b/calamares/src/modules/partition/tests/2d-overlap.conf @@ -0,0 +1,9 @@ +--- +efi: + mountPoint: "/boot/thisoverlaps" + minimumSize: 100MiB + recommendedSize: 300MiB + +efiSystemPartition: "/boot/ignored" +efiSystemPartitionSize: 175MiB +efiSystemPartitionName: legacy diff --git a/calamares/src/modules/partition/tests/3a-min-too-large.conf b/calamares/src/modules/partition/tests/3a-min-too-large.conf new file mode 100644 index 0000000..096f823 --- /dev/null +++ b/calamares/src/modules/partition/tests/3a-min-too-large.conf @@ -0,0 +1,5 @@ +--- +efi: + recommendedSize: 133MiB + minimumSize: 200MiB + label: "bigmin" diff --git a/calamares/src/modules/partition/tests/AutoMountTests.cpp b/calamares/src/modules/partition/tests/AutoMountTests.cpp new file mode 100644 index 0000000..68c929f --- /dev/null +++ b/calamares/src/modules/partition/tests/AutoMountTests.cpp @@ -0,0 +1,88 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "jobs/AutoMountManagementJob.h" + +#include "JobQueue.h" +#include "utils/Logger.h" + +#include +#include + +class AutoMountJobTests : public QObject +{ + Q_OBJECT +public: + AutoMountJobTests(); + +private Q_SLOTS: + void testRunThrice(); + void testRunQueue(); +}; + +AutoMountJobTests::AutoMountJobTests() {} + +/* This doesn't really test anything, since automount management + * is supposed to be opaque: the job always returns true. What + * is interesting is the debug output, where the job informs + * about the pointer it holds. + * + * That should output 0, then non-zero, then 0 again. + * + */ +void +AutoMountJobTests::testRunThrice() +{ + Logger::setupLogLevel( Logger::LOGVERBOSE ); + + auto original = Calamares::Partition::automountDisable( true ); + cDebug() << "Got automount info" << Logger::Pointer( original ); + + AutoMountManagementJob j( false ); + QVERIFY( j.exec() ); + QVERIFY( j.exec() ); + QVERIFY( j.exec() ); + + Calamares::Partition::automountRestore( original ); +} + +void +AutoMountJobTests::testRunQueue() +{ + Calamares::JobQueue q; + Calamares::job_ptr jp( new AutoMountManagementJob( false ) ); + QSignalSpy progress( &q, &Calamares::JobQueue::progress ); + QSignalSpy finish( &q, &Calamares::JobQueue::finished ); + QSignalSpy fail( &q, &Calamares::JobQueue::failed ); + + Logger::setupLogLevel( Logger::LOGVERBOSE ); + cDebug() << "Got automount job" << jp; + + QVERIFY( !q.isRunning() ); + q.enqueue( 2, { jp, jp } ); + QVERIFY( !q.isRunning() ); + + QEventLoop loop; + QTimer::singleShot( std::chrono::milliseconds( 100 ), [ &q ]() { q.start(); } ); + QTimer::singleShot( std::chrono::milliseconds( 5000 ), [ &loop ]() { loop.quit(); } ); + connect( &q, &Calamares::JobQueue::finished, &loop, &QEventLoop::quit ); + loop.exec(); + + QCOMPARE( fail.count(), 0 ); + QCOMPARE( finish.count(), 1 ); + // 5 progress: 0% and 100% for each *job* and then 100% overall + QCOMPARE( progress.count(), 5 ); +} + + +QTEST_GUILESS_MAIN( AutoMountJobTests ) + +#include "utils/moc-warnings.h" + +#include "AutoMountTests.moc" diff --git a/calamares/src/modules/partition/tests/CMakeLists.txt b/calamares/src/modules/partition/tests/CMakeLists.txt new file mode 100644 index 0000000..3418859 --- /dev/null +++ b/calamares/src/modules/partition/tests/CMakeLists.txt @@ -0,0 +1,80 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +find_package(${qtname} COMPONENTS Gui REQUIRED) + +set(PartitionModule_SOURCE_DIR ..) + +include_directories( + ${${qtname}Gui_INCLUDE_DIRS} + ${PartitionModule_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR} +) + +set(PartitionModule_basic_SRC + ${PartitionModule_SOURCE_DIR}/core/OsproberEntry.cpp + ${PartitionModule_SOURCE_DIR}/core/PartitionInfo.cpp + ${PartitionModule_SOURCE_DIR}/core/PartUtils.cpp + ) + +calamares_add_test( + partitionjobtest + SOURCES + PartitionJobTests.cpp + ${PartitionModule_SOURCE_DIR}/core/KPMHelpers.cpp + ${PartitionModule_SOURCE_DIR}/core/PartitionInfo.cpp + ${PartitionModule_SOURCE_DIR}/jobs/CreatePartitionJob.cpp + ${PartitionModule_SOURCE_DIR}/jobs/CreatePartitionTableJob.cpp + ${PartitionModule_SOURCE_DIR}/jobs/DeletePartitionJob.cpp + ${PartitionModule_SOURCE_DIR}/jobs/PartitionJob.cpp + ${PartitionModule_SOURCE_DIR}/jobs/ResizePartitionJob.cpp + LIBRARIES calamares::kpmcore + DEFINITIONS ${_partition_defs} +) + +calamares_add_test( + partitionclearmountsjobtest + SOURCES ${PartitionModule_SOURCE_DIR}/jobs/ClearMountsJob.cpp ClearMountsJobTests.cpp + LIBRARIES calamares::kpmcore + DEFINITIONS ${_partition_defs} +) + +calamares_add_test( + partitioncreatelayoutstest + SOURCES + CreateLayoutsTests.cpp + ${PartitionModule_basic_SRC} + ${PartitionModule_SOURCE_DIR}/core/KPMHelpers.cpp + ${PartitionModule_SOURCE_DIR}/core/PartitionLayout.cpp + ${PartitionModule_SOURCE_DIR}/core/DeviceModel.cpp + LIBRARIES calamares::kpmcore Calamares::calamaresui + DEFINITIONS ${_partition_defs} +) + +calamares_add_test( + partitionautomounttest + SOURCES ${PartitionModule_SOURCE_DIR}/jobs/AutoMountManagementJob.cpp AutoMountTests.cpp + DEFINITIONS ${_partition_defs} +) + +calamares_add_test( + partitiondevicestest + SOURCES DevicesTests.cpp ${PartitionModule_SOURCE_DIR}/core/DeviceList.cpp + LIBRARIES calamares::kpmcore + DEFINITIONS ${_partition_defs} +) + +calamares_add_test( + partitionconfigtest + SOURCES + ConfigTests.cpp + ${PartitionModule_basic_SRC} + ${PartitionModule_SOURCE_DIR}/core/DeviceModel.cpp + ${PartitionModule_SOURCE_DIR}/Config.cpp + LIBRARIES calamares::kpmcore Calamares::calamaresui + DEFINITIONS + ${_partition_defs} +) diff --git a/calamares/src/modules/partition/tests/ClearMountsJobTests.cpp b/calamares/src/modules/partition/tests/ClearMountsJobTests.cpp new file mode 100644 index 0000000..17565e7 --- /dev/null +++ b/calamares/src/modules/partition/tests/ClearMountsJobTests.cpp @@ -0,0 +1,68 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ClearMountsJobTests.h" + +#include "utils/Logger.h" + +#include + +QTEST_GUILESS_MAIN( ClearMountsJobTests ) + + +/* Not exactly public API */ +QStringList getPartitionsForDevice( const QString& deviceName ); + +/* At one point, the partitions-list was read from /proc/partitions by + * running awk and grep, as below. Check that the current implementation + * matches that crufty one. + * + * Update 2021-11-02: the newer implementation prepends /dev/ to the + * names of the partitions, for simplicity elsewhere, so that needs + * to be added in to the awk(1) program, too. + */ +QStringList +getPartitionsForDevice_other( const QString& deviceName ) +{ + QProcess process; + process.setProgram( "sh" ); + process.setArguments( { "-c", + QString( "echo $(awk '{print \"/dev/\"$4}' /proc/partitions | sed -e '/name/d' -e '/^$/d' " + "-e '/[1-9]/!d' | grep %1)" ) + .arg( deviceName ) } ); + process.start(); + process.waitForFinished(); + + const QString partitions = process.readAllStandardOutput().trimmed(); + if ( partitions.isEmpty() ) + { + return QStringList(); + } + const QStringList partitionsList = partitions.simplified().split( ' ' ); + + return partitionsList; +} + + +ClearMountsJobTests::ClearMountsJobTests() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +ClearMountsJobTests::testFindPartitions() +{ + QStringList partitions = getPartitionsForDevice( "sda" ); + QStringList other_part = getPartitionsForDevice_other( "sda" ); + + cDebug() << "Initial implementation:" << Logger::DebugList( partitions ); + cDebug() << "Other implementation:" << Logger::DebugList( other_part ); + + QCOMPARE( partitions, other_part ); +} diff --git a/calamares/src/modules/partition/tests/ClearMountsJobTests.h b/calamares/src/modules/partition/tests/ClearMountsJobTests.h new file mode 100644 index 0000000..4b13fdc --- /dev/null +++ b/calamares/src/modules/partition/tests/ClearMountsJobTests.h @@ -0,0 +1,25 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CLEARMOUNTSJOBTESTS_H +#define CLEARMOUNTSJOBTESTS_H + +#include + +class ClearMountsJobTests : public QObject +{ + Q_OBJECT +public: + ClearMountsJobTests(); + +private Q_SLOTS: + void testFindPartitions(); +}; + +#endif diff --git a/calamares/src/modules/partition/tests/ConfigTests.cpp b/calamares/src/modules/partition/tests/ConfigTests.cpp new file mode 100644 index 0000000..0e030d5 --- /dev/null +++ b/calamares/src/modules/partition/tests/ConfigTests.cpp @@ -0,0 +1,277 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2023 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "core/OsproberEntry.h" +#include "core/PartUtils.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include +#include +#include + +using Calamares::Units::operator""_MiB; + +class ConfigTests : public QObject +{ + Q_OBJECT + +public: + ConfigTests(); + +private Q_SLOTS: + void initTestCase(); + void testEmptyConfig(); + void testLegacySize(); + void testAll(); + void testWeirdConfig(); + + void testNormalFstab(); + void testWeirdFstab(); +}; + +ConfigTests::ConfigTests() = default; + +void +ConfigTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGVERBOSE ); + + // Ensure we have a system object, expect it to be a "bogus" one + Calamares::System* system = Calamares::System::instance(); + QVERIFY( system ); + QVERIFY( system->doChroot() ); + + // Ensure we have a system-wide GlobalStorage with /tmp as root + if ( !Calamares::JobQueue::instance() ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + } + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); +} + + +void +ConfigTests::testEmptyConfig() +{ + Config c( nullptr ); + c.setConfigurationMap( {} ); + + const auto* gs = Calamares::JobQueue::instanceGlobalStorage(); + QVERIFY( gs ); + + QVERIFY( c.initialInstallChoice() == Config::InstallChoice::NoChoice ); + QVERIFY( !gs->value( PartUtils::efiFilesystemRecommendedSizeGSKey() ).isValid() ); // Nothing filled in + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 300_MiB ); // Default value + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 300_MiB ); // Default value + + const auto firmware = gs->value( "firmwareType" ).toString(); + QVERIFY( firmware == "efi" || firmware == "bios" ); + + QCOMPARE( gs->value( "efiSystemPartition" ).toString(), "/boot/efi" ); // Default +} + +void +ConfigTests::testLegacySize() +{ + Config c( nullptr ); + + auto* gs = Calamares::JobQueue::instanceGlobalStorage(); + QVERIFY( gs ); + + + // Config with just one legacy key + { + const auto file = QStringLiteral( BUILD_AS_TEST "/1a-legacy.conf" ); + bool ok = false; + c.setConfigurationMap( Calamares::YAML::load( file, &ok ) ); + + cDebug() << "Tried to load" << file << "success?" << ok; + + QVERIFY( ok ); + + QVERIFY( gs->value( PartUtils::efiFilesystemRecommendedSizeGSKey() ).isValid() ); // Something was filled in + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 100_MiB ); // From config + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 100_MiB ); // Taken from config + } + + // Different legacy key value + { + bool ok = false; + c.setConfigurationMap( Calamares::YAML::load( QStringLiteral( BUILD_AS_TEST "/1b-legacy.conf" ), &ok ) ); + + QVERIFY( ok ); + + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 100000000 ); // From config, MB + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 100000000 ); // Taken from config + } +} + +void +ConfigTests::testAll() +{ + Config c( nullptr ); + + auto* gs = Calamares::JobQueue::instanceGlobalStorage(); + QVERIFY( gs ); + + + // Legacy only + { + gs->clear(); + const auto file = QStringLiteral( BUILD_AS_TEST "/2a-legacy.conf" ); + bool ok = false; + c.setConfigurationMap( Calamares::YAML::load( file, &ok ) ); + + cDebug() << "Tried to load" << file << "success?" << ok; + + QVERIFY( ok ); + + QVERIFY( gs->value( PartUtils::efiFilesystemRecommendedSizeGSKey() ).isValid() ); // Something was filled in + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 75_MiB ); // From config + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 75_MiB ); // No separate setting + + QCOMPARE( gs->value( "efiSystemPartition" ).toString(), QStringLiteral( "/boot/thisisatest" ) ); + QCOMPARE( gs->value( "efiSystemPartitionName" ).toString(), QStringLiteral( "testLabel" ) ); + } + + // Modern only + { + gs->clear(); + bool ok = false; + c.setConfigurationMap( Calamares::YAML::load( QStringLiteral( BUILD_AS_TEST "/2b-modern.conf" ), &ok ) ); + + QVERIFY( ok ); + + QVERIFY( PartUtils::efiFilesystemRecommendedSizeGSKey() != PartUtils::efiFilesystemMinimumSizeGSKey() ); + QCOMPARE( gs->value( PartUtils::efiFilesystemRecommendedSizeGSKey() ).toString(), + QStringLiteral( "83886080" ) ); + QCOMPARE( gs->value( PartUtils::efiFilesystemMinimumSizeGSKey() ).toString(), QStringLiteral( "68157440" ) ); + + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 80_MiB ); // From config + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 65_MiB ); // Taken from config + + QCOMPARE( gs->value( "efiSystemPartition" ).toString(), QStringLiteral( "/boot/thisismodern" ) ); + QCOMPARE( gs->value( "efiSystemPartitionName" ).toString(), QStringLiteral( "UEFI" ) ); + } + + // Mixed settings + { + gs->clear(); + bool ok = false; + c.setConfigurationMap( Calamares::YAML::load( QStringLiteral( BUILD_AS_TEST "/2c-mixed.conf" ), &ok ) ); + + QVERIFY( ok ); + + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 175_MiB ); // From config + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 80_MiB ); // Taken from config + + QCOMPARE( gs->value( "efiSystemPartition" ).toString(), QStringLiteral( "/boot/thisismixed" ) ); + QCOMPARE( gs->value( "efiSystemPartitionName" ).toString(), QStringLiteral( "legacy" ) ); + } + + // Mixed settings with overlap + { + gs->clear(); + bool ok = false; + c.setConfigurationMap( Calamares::YAML::load( QStringLiteral( BUILD_AS_TEST "/2d-overlap.conf" ), &ok ) ); + + QVERIFY( ok ); + + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 300_MiB ); // From modern config + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 100_MiB ); // Taken from modern config, legacy ignored + + QCOMPARE( gs->value( "efiSystemPartition" ).toString(), QStringLiteral( "/boot/thisoverlaps" ) ); + QCOMPARE( gs->value( "efiSystemPartitionName" ).toString(), QStringLiteral( "legacy" ) ); + } +} + +void +ConfigTests::testWeirdConfig() +{ + Config c( nullptr ); + + auto* gs = Calamares::JobQueue::instanceGlobalStorage(); + QVERIFY( gs ); + gs->clear(); + + + // Config with an invalid minimum size + { + const auto file = QStringLiteral( BUILD_AS_TEST "/3a-min-too-large.conf" ); + bool ok = false; + c.setConfigurationMap( Calamares::YAML::load( file, &ok ) ); + + cDebug() << "Tried to load" << file << "success?" << ok; + QVERIFY( ok ); + + QCOMPARE( PartUtils::efiFilesystemRecommendedSize(), 133_MiB ); + QCOMPARE( PartUtils::efiFilesystemMinimumSize(), 133_MiB ); // Config setting was ignored + + QCOMPARE( gs->value( "efiSystemPartitionName" ).toString(), QStringLiteral( "bigmin" ) ); + } +} + +void +ConfigTests::testNormalFstab() +{ + const auto contents + = QByteArrayLiteral( "# A FreeBSD fstab\n" + "/dev/nvd0p3 none swap sw 0 0\n" ); + const auto entries = Calamares::fromEtcFstabContents( contents ); + for ( const auto& e : entries ) + { + QVERIFY( e.isValid() ); + } + QCOMPARE( entries.count(), 1 ); +} + +void +ConfigTests::testWeirdFstab() +{ + const auto contents + = QByteArrayLiteral( "# \n" + "UUID=dae80d0a-f6c7-46f4-a04a-6761f2cfd9b6 / ext4 defaults,noatime 0 1\n" + "UUID=423892d5-a929-41a9-a846-f410cf3fe25b swap swap defaults,noatime 0 2\n" + "# another comment\n" + "borked 2\n" + "ok /dev1 ext4 none 0 0\n" + "bogus /dev2 ext4 none no later\n" + "# comment\n" ); + const auto entries = Calamares::fromEtcFstabContents( contents ); + QCOMPARE( entries.count(), 4 ); + + QStringList mountPoints; + for ( const auto& e : entries ) + { + mountPoints.append( e.mountPoint ); + } + mountPoints.sort(); + QCOMPARE( mountPoints, + QStringList() << "/" + << "/dev1" + << "/dev2" + << "swap" ); +} + + +QTEST_GUILESS_MAIN( ConfigTests ) + +#include "utils/moc-warnings.h" + +#include "ConfigTests.moc" diff --git a/calamares/src/modules/partition/tests/CreateLayoutsTests.cpp b/calamares/src/modules/partition/tests/CreateLayoutsTests.cpp new file mode 100644 index 0000000..3562ab9 --- /dev/null +++ b/calamares/src/modules/partition/tests/CreateLayoutsTests.cpp @@ -0,0 +1,156 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Corentin Noël + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CreateLayoutsTests.h" + +#include "core/PartitionLayout.h" + +#include "JobQueue.h" +#include "partition/KPMManager.h" +#include "utils/Logger.h" + +#include + +#include + +using namespace Calamares::Units; + +class PartitionTable; +class SmartStatus; + +QTEST_GUILESS_MAIN( CreateLayoutsTests ) + +static Calamares::Partition::KPMManager* kpmcore = nullptr; +static Calamares::JobQueue* jobqueue = nullptr; + +#define LOGICAL_SIZE 512 + +CreateLayoutsTests::CreateLayoutsTests() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +CreateLayoutsTests::init() +{ + jobqueue = new Calamares::JobQueue( nullptr ); + kpmcore = new Calamares::Partition::KPMManager(); +} + +void +CreateLayoutsTests::cleanup() +{ + delete kpmcore; + delete jobqueue; +} + +void +CreateLayoutsTests::testFixedSizePartition() +{ + const PartitionRole role( PartitionRole::Role::Any ); + + PartitionLayout layout = PartitionLayout(); + TestDevice dev( QString( "test" ), LOGICAL_SIZE, 5_GiB / LOGICAL_SIZE ); + PartitionTable table( PartitionTable::TableType::msdos, 0, 5_GiB ); + + if ( !layout.addEntry( { FileSystem::Type::Ext4, QString( "/" ), QString( "5MiB" ) } ) ) + { + QFAIL( qPrintable( "Unable to create / partition" ) ); + } + + const auto partitions = layout.createPartitions( + static_cast< Device* >( &dev ), 0, dev.totalLogical(), Config::LuksGeneration::Luks1, nullptr, &table, role ); + + QCOMPARE( partitions.count(), 1 ); + + QCOMPARE( partitions[ 0 ]->length(), 5_MiB / LOGICAL_SIZE ); +} + +void +CreateLayoutsTests::testPercentSizePartition() +{ + const PartitionRole role( PartitionRole::Role::Any ); + + PartitionLayout layout = PartitionLayout(); + TestDevice dev( QString( "test" ), LOGICAL_SIZE, 5_GiB / LOGICAL_SIZE ); + PartitionTable table( PartitionTable::TableType::msdos, 0, 5_GiB ); + + if ( !layout.addEntry( { FileSystem::Type::Ext4, QString( "/" ), QString( "50%" ) } ) ) + { + QFAIL( qPrintable( "Unable to create / partition" ) ); + } + + const auto partitions = layout.createPartitions( + static_cast< Device* >( &dev ), 0, dev.totalLogical(), Config::LuksGeneration::Luks1, nullptr, &table, role ); + + QCOMPARE( partitions.count(), 1 ); + + QCOMPARE( partitions[ 0 ]->length(), ( 5_GiB / 2 ) / LOGICAL_SIZE ); +} + +void +CreateLayoutsTests::testMixedSizePartition() +{ + const PartitionRole role( PartitionRole::Role::Any ); + + PartitionLayout layout = PartitionLayout(); + TestDevice dev( QString( "test" ), LOGICAL_SIZE, 5_GiB / LOGICAL_SIZE ); + PartitionTable table( PartitionTable::TableType::msdos, 0, 5_GiB ); + + if ( !layout.addEntry( { FileSystem::Type::Ext4, QString( "/" ), QString( "5MiB" ) } ) ) + { + QFAIL( qPrintable( "Unable to create / partition" ) ); + } + + if ( !layout.addEntry( { FileSystem::Type::Ext4, QString( "/home" ), QString( "50%" ) } ) ) + { + QFAIL( qPrintable( "Unable to create /home partition" ) ); + } + + if ( !layout.addEntry( { FileSystem::Type::Ext4, QString( "/bkup" ), QString( "50%" ) } ) ) + { + QFAIL( qPrintable( "Unable to create /bkup partition" ) ); + } + + const auto partitions = layout.createPartitions( + static_cast< Device* >( &dev ), 0, dev.totalLogical(), Config::LuksGeneration::Luks1, nullptr, &table, role ); + + QCOMPARE( partitions.count(), 3 ); + + QCOMPARE( partitions[ 0 ]->length(), 5_MiB / LOGICAL_SIZE ); + QCOMPARE( partitions[ 1 ]->length(), ( ( 5_GiB - 5_MiB ) / 2 ) / LOGICAL_SIZE ); + QCOMPARE( partitions[ 2 ]->length(), ( ( 5_GiB - 5_MiB ) / 2 ) / LOGICAL_SIZE ); +} + +// TODO: Get a clean way to instantiate a test Device from KPMCore +class DevicePrivate +{ +public: + QString m_Name; + QString m_DeviceNode; + qint64 m_LogicalSectorSize; + qint64 m_TotalLogical; + PartitionTable* m_PartitionTable; + QString m_IconName; + std::shared_ptr< SmartStatus > m_SmartStatus; + Device::Type m_Type; +}; + +TestDevice::TestDevice( const QString& name, const qint64 logicalSectorSize, const qint64 totalLogicalSectors ) + : Device( std::make_shared< DevicePrivate >(), + name, + QString( "node" ), + logicalSectorSize, + totalLogicalSectors, + QString(), + Device::Type::Unknown_Device ) +{ +} + +TestDevice::~TestDevice() {} diff --git a/calamares/src/modules/partition/tests/CreateLayoutsTests.h b/calamares/src/modules/partition/tests/CreateLayoutsTests.h new file mode 100644 index 0000000..5953b06 --- /dev/null +++ b/calamares/src/modules/partition/tests/CreateLayoutsTests.h @@ -0,0 +1,39 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Corentin Noël + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CLEARMOUNTSJOBTESTS_H +#define CLEARMOUNTSJOBTESTS_H + +#include "partition/KPMHelper.h" + +#include + +class CreateLayoutsTests : public QObject +{ + Q_OBJECT +public: + CreateLayoutsTests(); + ~CreateLayoutsTests() override = default; + +private Q_SLOTS: + void testFixedSizePartition(); + void testPercentSizePartition(); + void testMixedSizePartition(); + void init(); + void cleanup(); +}; + +class TestDevice : public Device +{ +public: + TestDevice( const QString& name, const qint64 logicalSectorSize, const qint64 totalLogicalSectors ); + ~TestDevice() override; +}; + +#endif diff --git a/calamares/src/modules/partition/tests/DevicesTests.cpp b/calamares/src/modules/partition/tests/DevicesTests.cpp new file mode 100644 index 0000000..16666d1 --- /dev/null +++ b/calamares/src/modules/partition/tests/DevicesTests.cpp @@ -0,0 +1,90 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "core/DeviceList.h" + +#include "partition/KPMManager.h" +#include "utils/Logger.h" + +#include +#include + +#include +#include + +#include + +#include + +class DevicesTests : public QObject +{ + Q_OBJECT + +public: + DevicesTests(); + +private Q_SLOTS: + void testKPMScanDevices(); + void testPartUtilScanDevices(); + +private: + std::unique_ptr< Calamares::Partition::KPMManager > m_d; + bool m_isRoot = false; +}; + +DevicesTests::DevicesTests() + : m_d( std::make_unique< Calamares::Partition::KPMManager >() ) + , m_isRoot( geteuid() == 0 ) +{ +} + +void +DevicesTests::testKPMScanDevices() +{ + Logger::setupLogLevel( Logger::LOGVERBOSE ); + + cDebug() << "Getting devices via KPMCore"; + CoreBackend* backend = CoreBackendManager::self()->backend(); +#ifdef Q_OS_FREEBSD + QEXPECT_FAIL( "", "Test backend not expected on FreeBSD", Continue ); + QVERIFY( backend ); + return; +#endif + QVERIFY( backend ); + auto devices = backend->scanDevices( ScanFlag( ~0 ) ); // These flags try to get "all" + cDebug() << Logger::SubEntry << "Done getting devices."; + + if ( !m_isRoot ) + { + QEXPECT_FAIL( "", "Test invalid when not root", Continue ); + } + QVERIFY( devices.count() > 0 ); +} + +void +DevicesTests::testPartUtilScanDevices() +{ + Logger::setupLogLevel( Logger::LOGVERBOSE ); + + cDebug() << "Getting devices via PartUtils"; + auto devices = PartUtils::getDevices(); + cDebug() << Logger::SubEntry << "Done getting devices."; + + if ( !m_isRoot ) + { + QEXPECT_FAIL( "", "Test invalid when not root", Continue ); + } + QVERIFY( devices.count() > 0 ); +} + +QTEST_GUILESS_MAIN( DevicesTests ) + +#include "utils/moc-warnings.h" + +#include "DevicesTests.moc" diff --git a/calamares/src/modules/partition/tests/PartitionJobTests.cpp b/calamares/src/modules/partition/tests/PartitionJobTests.cpp new file mode 100644 index 0000000..24686b5 --- /dev/null +++ b/calamares/src/modules/partition/tests/PartitionJobTests.cpp @@ -0,0 +1,443 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2017, 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PartitionJobTests.h" + +#include "core/KPMHelpers.h" +#include "jobs/CreatePartitionJob.h" +#include "jobs/CreatePartitionTableJob.h" +#include "jobs/ResizePartitionJob.h" + +#include "partition/KPMHelper.h" +#include "partition/KPMManager.h" +#include "partition/PartitionQuery.h" +#include "utils/Logger.h" +#include "utils/Units.h" + +#include +#include +#include + +QTEST_GUILESS_MAIN( PartitionJobTests ) + +using Calamares::job_ptr; +using Calamares::JobList; +using namespace Calamares::Units; + +class PartitionMounter +{ +public: + PartitionMounter( const QString& devicePath ) + : m_mountPointDir( "calamares-partitiontests-mountpoint" ) + { + QStringList args = QStringList() << devicePath << m_mountPointDir.path(); + int ret = QProcess::execute( "mount", args ); + m_mounted = ret == 0; + QCOMPARE( ret, 0 ); + } + + ~PartitionMounter() + { + if ( !m_mounted ) + { + return; + } + int ret = QProcess::execute( "umount", QStringList() << m_mountPointDir.path() ); + QCOMPARE( ret, 0 ); + } + + QString mountPoint() const { return m_mounted ? m_mountPointDir.path() : QString(); } + +private: + QString m_devicePath; + QTemporaryDir m_mountPointDir; + bool m_mounted; +}; + +/// @brief Generate random data of given @p size as a QByteArray +static QByteArray +generateTestData( qint64 size ) +{ + QByteArray ba; + ba.resize( static_cast< int >( size ) ); + // Fill the array explicitly to keep Valgrind happy + for ( auto it = ba.data(); it < ba.data() + size; ++it ) + { + *it = char( rand() & 0xff ); + } + return ba; +} + +static void +writeFile( const QString& path, const QByteArray data ) +{ + QFile file( path ); + QVERIFY( file.open( QIODevice::WriteOnly ) ); + + const char* ptr = data.constData(); + const char* end = data.constData() + data.size(); + const qint64 chunkSize = 16384; + + while ( ptr < end ) + { + qint64 count = file.write( ptr, chunkSize ); + if ( count < 0 ) + { + QString msg = QString( "Writing file failed. Only %1 bytes written out of %2. Error: '%3'." ) + .arg( ptr - data.constData() ) + .arg( data.size() ) + .arg( file.errorString() ); + QFAIL( qPrintable( msg ) ); + } + ptr += count; + } +} + +static ::Partition* +firstFreePartition( PartitionNode* parent ) +{ + for ( auto child : parent->children() ) + { + if ( Calamares::Partition::isPartitionFreeSpace( child ) ) + { + return child; + } + } + return nullptr; +} + +//- QueueRunner --------------------------------------------------------------- +QueueRunner::QueueRunner( Calamares::JobQueue* queue ) + : m_queue( queue ) + , m_finished( false ) // Same initalizations as in ::run() + , m_success( true ) +{ + connect( m_queue, &Calamares::JobQueue::finished, this, &QueueRunner::onFinished ); + connect( m_queue, &Calamares::JobQueue::failed, this, &QueueRunner::onFailed ); +} + +QueueRunner::~QueueRunner() +{ + // Nothing to do. We don't own the queue, and disconnect happens automatically +} + +bool +QueueRunner::run() +{ + m_finished = false; + m_success = true; + m_queue->start(); + QEventLoop loop; + while ( !m_finished ) + { + loop.processEvents(); + } + return m_success; +} + +void +QueueRunner::onFinished() +{ + m_finished = true; +} + +void +QueueRunner::onFailed( const QString& message, const QString& details ) +{ + m_success = false; + QString msg = message + "\ndetails: " + details; + QFAIL( qPrintable( msg ) ); +} + +static Calamares::Partition::KPMManager* kpmcore = nullptr; + +//- PartitionJobTests ------------------------------------------------------------------ +PartitionJobTests::PartitionJobTests() + : m_runner( &m_queue ) +{ +} + +void +PartitionJobTests::initTestCase() +{ + QString devicePath = qgetenv( "CALAMARES_TEST_DISK" ); + if ( devicePath.isEmpty() ) + { + // The 0 is to keep the macro parameters happy + QSKIP( "Skipping test, CALAMARES_TEST_DISK is not set. It should point to a disk which can be safely formatted", + 0 ); + } + + kpmcore = new Calamares::Partition::KPMManager(); + FileSystemFactory::init(); + + refreshDevice(); +} + +void +PartitionJobTests::cleanupTestCase() +{ + delete kpmcore; +} + +void +PartitionJobTests::refreshDevice() +{ + QString devicePath = qgetenv( "CALAMARES_TEST_DISK" ); + m_device.reset( kpmcore->backend()->scanDevice( devicePath ) ); + QVERIFY( !m_device.isNull() ); +} + +void +PartitionJobTests::testPartitionTable() +{ + queuePartitionTableCreation( PartitionTable::msdos ); + QVERIFY( m_runner.run() ); + QVERIFY( m_device->partitionTable() ); + QVERIFY( firstFreePartition( m_device->partitionTable() ) ); + + queuePartitionTableCreation( PartitionTable::gpt ); + QVERIFY( m_runner.run() ); + QVERIFY( m_device->partitionTable() ); + QVERIFY( firstFreePartition( m_device->partitionTable() ) ); +} + +void +PartitionJobTests::queuePartitionTableCreation( PartitionTable::TableType type ) +{ + auto job = new CreatePartitionTableJob( m_device.data(), type ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); +} + +CreatePartitionJob* +PartitionJobTests::newCreatePartitionJob( Partition* freeSpacePartition, + PartitionRole role, + FileSystem::Type type, + qint64 size ) +{ + Q_ASSERT( freeSpacePartition ); + + qint64 firstSector = freeSpacePartition->firstSector(); + qint64 lastSector; + + if ( size > 0 ) + { + lastSector = firstSector + size / m_device->logicalSize(); + } + else + { + lastSector = freeSpacePartition->lastSector(); + } + FileSystem* fs = FileSystemFactory::create( type, firstSector, lastSector, m_device->logicalSize() ); + + Partition* partition = new Partition( freeSpacePartition->parent(), + *m_device, + role, + fs, + firstSector, + lastSector, + QString() /* path */, + KPM_PARTITION_FLAG( None ) /* availableFlags */, + QString() /* mountPoint */, + false /* mounted */, + KPM_PARTITION_FLAG( None ) /* activeFlags */, + KPM_PARTITION_STATE( New ) ); + return new CreatePartitionJob( m_device.data(), partition ); +} + +void +PartitionJobTests::testCreatePartition() +{ + queuePartitionTableCreation( PartitionTable::gpt ); + CreatePartitionJob* job; + Partition* freePartition; + + freePartition = firstFreePartition( m_device->partitionTable() ); + QVERIFY( freePartition ); + job = newCreatePartitionJob( freePartition, PartitionRole( PartitionRole::Primary ), FileSystem::Ext4, 1_MiB ); + Partition* partition1 = job->partition(); + QVERIFY( partition1 ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + + freePartition = firstFreePartition( m_device->partitionTable() ); + QVERIFY( freePartition ); + job = newCreatePartitionJob( freePartition, PartitionRole( PartitionRole::Primary ), FileSystem::LinuxSwap, 1_MiB ); + Partition* partition2 = job->partition(); + QVERIFY( partition2 ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + + freePartition = firstFreePartition( m_device->partitionTable() ); + QVERIFY( freePartition ); + job = newCreatePartitionJob( freePartition, PartitionRole( PartitionRole::Primary ), FileSystem::Fat32, 1_MiB ); + Partition* partition3 = job->partition(); + QVERIFY( partition3 ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + + QVERIFY( m_runner.run() ); + + // Check partitionPath has been set. It is not known until the job has + // executed. + QString devicePath = m_device->deviceNode(); + QCOMPARE( partition1->partitionPath(), devicePath + "1" ); + QCOMPARE( partition2->partitionPath(), devicePath + "2" ); + QCOMPARE( partition3->partitionPath(), devicePath + "3" ); +} + +void +PartitionJobTests::testCreatePartitionExtended() +{ + queuePartitionTableCreation( PartitionTable::msdos ); + CreatePartitionJob* job; + Partition* freePartition; + + freePartition = firstFreePartition( m_device->partitionTable() ); + QVERIFY( freePartition ); + job = newCreatePartitionJob( freePartition, PartitionRole( PartitionRole::Primary ), FileSystem::Ext4, 10_MiB ); + Partition* partition1 = job->partition(); + QVERIFY( partition1 ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + + freePartition = firstFreePartition( m_device->partitionTable() ); + QVERIFY( freePartition ); + job = newCreatePartitionJob( + freePartition, PartitionRole( PartitionRole::Extended ), FileSystem::Extended, 10_MiB ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + Partition* extendedPartition = job->partition(); + + freePartition = firstFreePartition( extendedPartition ); + QVERIFY( freePartition ); + job = newCreatePartitionJob( freePartition, PartitionRole( PartitionRole::Logical ), FileSystem::Ext4, 0 ); + Partition* partition2 = job->partition(); + QVERIFY( partition2 ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + + QVERIFY( m_runner.run() ); + + // Check partitionPath has been set. It is not known until the job has + // executed. + QString devicePath = m_device->deviceNode(); + QCOMPARE( partition1->partitionPath(), devicePath + "1" ); + QCOMPARE( extendedPartition->partitionPath(), devicePath + "2" ); + QCOMPARE( partition2->partitionPath(), devicePath + "5" ); +} + +void +PartitionJobTests::testResizePartition_data() +{ + QTest::addColumn< unsigned int >( "oldStartMiB" ); + QTest::addColumn< unsigned int >( "oldSizeMiB" ); + QTest::addColumn< unsigned int >( "newStartMiB" ); + QTest::addColumn< unsigned int >( "newSizeMiB" ); + + QTest::newRow( "grow" ) << 10 << 50 << 10 << 70; + QTest::newRow( "shrink" ) << 10 << 70 << 10 << 50; + QTest::newRow( "moveLeft" ) << 10 << 50 << 8 << 50; + QTest::newRow( "moveRight" ) << 10 << 50 << 12 << 50; +} + +void +PartitionJobTests::testResizePartition() +{ + QFETCH( unsigned int, oldStartMiB ); + QFETCH( unsigned int, oldSizeMiB ); + QFETCH( unsigned int, newStartMiB ); + QFETCH( unsigned int, newSizeMiB ); + + const qint64 sectorsPerMiB = 1_MiB / m_device->logicalSize(); + + qint64 oldFirst = sectorsPerMiB * oldStartMiB; + qint64 oldLast = oldFirst + sectorsPerMiB * oldSizeMiB - 1; + qint64 newFirst = sectorsPerMiB * newStartMiB; + qint64 newLast = newFirst + sectorsPerMiB * newSizeMiB - 1; + + // Make the test data file smaller than the full size of the partition to + // accomodate for the file system overhead + const unsigned long long minSizeMiB = qMin( oldSizeMiB, newSizeMiB ); + const QByteArray testData = generateTestData( Calamares::MiBtoBytes( minSizeMiB ) * 3 / 4 ); + const QString testName = "test.data"; + + // Setup: create the test partition + { + queuePartitionTableCreation( PartitionTable::msdos ); + + Partition* freePartition = firstFreePartition( m_device->partitionTable() ); + QVERIFY( freePartition ); + Partition* partition = KPMHelpers::createNewPartition( freePartition->parent(), + *m_device, + PartitionRole( PartitionRole::Primary ), + FileSystem::Ext4, + QStringLiteral( "testp" ), + oldFirst, + oldLast, + KPM_PARTITION_FLAG( None ) ); + CreatePartitionJob* job = new CreatePartitionJob( m_device.data(), partition ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + + QVERIFY( m_runner.run() ); + } + + { + // Write a test file in the partition + refreshDevice(); + QVERIFY( m_device->partitionTable() ); + Partition* partition + = m_device->partitionTable()->findPartitionBySector( oldFirst, PartitionRole( PartitionRole::Primary ) ); + QVERIFY( partition ); + QCOMPARE( partition->firstSector(), oldFirst ); + QCOMPARE( partition->lastSector(), oldLast ); + { + PartitionMounter mounter( partition->partitionPath() ); + QString mountPoint = mounter.mountPoint(); + QVERIFY( !mountPoint.isEmpty() ); + writeFile( mountPoint + '/' + testName, testData ); + } + + // Resize + ResizePartitionJob* job = new ResizePartitionJob( m_device.data(), partition, newFirst, newLast ); + job->updatePreview(); + m_queue.enqueue( 1, JobList() << job_ptr( job ) ); + QVERIFY( m_runner.run() ); + + QCOMPARE( partition->firstSector(), newFirst ); + QCOMPARE( partition->lastSector(), newLast ); + } + + // Test + { + refreshDevice(); + QVERIFY( m_device->partitionTable() ); + Partition* partition + = m_device->partitionTable()->findPartitionBySector( newFirst, PartitionRole( PartitionRole::Primary ) ); + QVERIFY( partition ); + QCOMPARE( partition->firstSector(), newFirst ); + QCOMPARE( partition->lastSector(), newLast ); + QCOMPARE( partition->fileSystem().firstSector(), newFirst ); + QCOMPARE( partition->fileSystem().lastSector(), newLast ); + + PartitionMounter mounter( partition->partitionPath() ); + QString mountPoint = mounter.mountPoint(); + QVERIFY( !mountPoint.isEmpty() ); + { + QFile file( mountPoint + '/' + testName ); + QVERIFY( file.open( QIODevice::ReadOnly ) ); + QByteArray outData = file.readAll(); + QCOMPARE( outData.size(), testData.size() ); + QCOMPARE( outData, testData ); + } + } +} diff --git a/calamares/src/modules/partition/tests/PartitionJobTests.h b/calamares/src/modules/partition/tests/PartitionJobTests.h new file mode 100644 index 0000000..9e4455d --- /dev/null +++ b/calamares/src/modules/partition/tests/PartitionJobTests.h @@ -0,0 +1,66 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTITIONJOBTESTS_H +#define PARTITIONJOBTESTS_H + +#include "JobQueue.h" + +#include "partition/KPMHelper.h" + +// Qt +#include +#include + +class QueueRunner : public QObject +{ +public: + QueueRunner( Calamares::JobQueue* queue ); + ~QueueRunner() override; + + /** + * Synchronously runs the queue. Returns true on success + */ + bool run(); + +private: + void onFailed( const QString& message, const QString& details ); + void onFinished(); + Calamares::JobQueue* m_queue; + bool m_finished; + bool m_success; +}; + +class PartitionJobTests : public QObject +{ + Q_OBJECT +public: + PartitionJobTests(); + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + void testPartitionTable(); + void testCreatePartition(); + void testCreatePartitionExtended(); + void testResizePartition_data(); + void testResizePartition(); + +private: + QScopedPointer< Device > m_device; + Calamares::JobQueue m_queue; + QueueRunner m_runner; + + void queuePartitionTableCreation( PartitionTable::TableType type ); + CreatePartitionJob* + newCreatePartitionJob( Partition* freeSpacePartition, PartitionRole, FileSystem::Type type, qint64 size ); + void refreshDevice(); +}; + +#endif /* PARTITIONJOBTESTS_H */ diff --git a/calamares/src/modules/plasmalnf/CMakeLists.txt b/calamares/src/modules/plasmalnf/CMakeLists.txt new file mode 100644 index 0000000..930b3e4 --- /dev/null +++ b/calamares/src/modules/plasmalnf/CMakeLists.txt @@ -0,0 +1,53 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-FileCopyrightText: 2024 Anke Boersma +# SPDX-License-Identifier: BSD-2-Clause +# + +# Requires a sufficiently recent Plasma framework, but also +# needs a runtime support component (which we don't test for). + +if(WITH_QT6) + set(PLASMA_VERSION "5.93.0") + set(_plasma_libraries "Plasma::Plasma") + set(_plasma_name "Plasma") + find_package(${kfname} ${KF_VERSION} QUIET COMPONENTS Config Package) + find_package(Plasma ${PLASMA_VERSION} QUIET) +else() + set(_plasma_libraries "${kfname}::Plasma") + set(_plasma_name "KF5Plasma") + find_package(${kfname} ${KF_VERSION} QUIET COMPONENTS Config Plasma Package) +endif() + +set_package_properties(${kfname}Config PROPERTIES PURPOSE "For finding default Plasma Look-and-Feel") +set_package_properties(${_plasma_name} PROPERTIES PURPOSE "For Plasma Look-and-Feel selection") +set_package_properties(${kfname}Package PROPERTIES PURPOSE "For Plasma Look-and-Feel selection") + +if(${_plasma_name}_FOUND AND ${kfname}Package_FOUND) + calamares_add_plugin(plasmalnf + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + COMPILE_DEFINITIONS + ${option_defs} + SOURCES + Config.cpp + PlasmaLnfViewStep.cpp + PlasmaLnfPage.cpp + PlasmaLnfJob.cpp + ThemeInfo.cpp + RESOURCES + page_plasmalnf.qrc + UI + page_plasmalnf.ui + LINK_PRIVATE_LIBRARIES + ${kfname}::Package + ${_plasma_libraries} + SHARED_LIB + ) + if(${kfname}Config_FOUND) + target_compile_definitions(calamares_viewmodule_plasmalnf PRIVATE WITH_KCONFIG) + endif() +else() + calamares_skip_module( "plasmalnf (missing requirements)" ) +endif() diff --git a/calamares/src/modules/plasmalnf/Config.cpp b/calamares/src/modules/plasmalnf/Config.cpp new file mode 100644 index 0000000..052e569 --- /dev/null +++ b/calamares/src/modules/plasmalnf/Config.cpp @@ -0,0 +1,167 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "PlasmaLnfJob.h" +#include "ThemeInfo.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#ifdef WITH_KCONFIG +#include +#include +#endif + +#include + +static QString +currentPlasmaTheme() +{ +#ifdef WITH_KCONFIG + KConfigGroup cg( KSharedConfig::openConfig( QStringLiteral( "kdeglobals" ) ), "KDE" ); + return cg.readEntry( "LookAndFeelPackage", QString() ); +#else + cWarning() << "No KConfig support, cannot determine Plasma theme."; + return QString(); +#endif +} + +Config::Config( QObject* parent ) + : QObject( parent ) + , m_themeModel( new ThemesModel( this ) ) +{ + auto* filter = new QSortFilterProxyModel( m_themeModel ); + filter->setFilterRole( ThemesModel::ShownRole ); + filter->setFilterFixedString( QStringLiteral( "true" ) ); + filter->setSourceModel( m_themeModel ); + filter->setSortRole( ThemesModel::LabelRole ); + filter->sort( 0 ); + + m_filteredModel = filter; +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_lnfPath = Calamares::getString( configurationMap, "lnftool" ); + + if ( m_lnfPath.isEmpty() ) + { + cWarning() << "no lnftool given for plasmalnf module."; + } + + m_liveUser = Calamares::getString( configurationMap, "liveuser" ); + + QString preselect = Calamares::getString( configurationMap, "preselect" ); + if ( preselect == QStringLiteral( "*" ) ) + { + preselect = currentPlasmaTheme(); + } + m_preselectThemeId = preselect; + + if ( configurationMap.contains( "themes" ) + && Calamares::typeOf( configurationMap.value( "themes" ) ) == Calamares::StringVariantType ) + { + QMap< QString, QString > listedThemes; + auto themeList = configurationMap.value( "themes" ).toList(); + // Create the ThemInfo objects for the listed themes; information + // about the themes from Plasma (e.g. human-readable name and description) + // are filled in by update_names() in PlasmaLnfPage. + for ( const auto& i : themeList ) + { + if ( Calamares::typeOf( i ) == Calamares::MapVariantType ) + { + auto iv = i.toMap(); + listedThemes.insert( iv.value( "theme" ).toString(), iv.value( "image" ).toString() ); + } + else if ( Calamares::typeOf( i ) == Calamares::StringVariantType ) + { + listedThemes.insert( i.toString(), QString() ); + } + } + + if ( listedThemes.count() == 1 ) + { + cWarning() << "only one theme enabled in plasmalnf"; + } + m_themeModel->setThemeImage( listedThemes ); + + bool showAll = Calamares::getBool( configurationMap, "showAll", false ); + if ( !listedThemes.isEmpty() && !showAll ) + { + m_themeModel->showOnlyThemes( listedThemes ); + } + } + + m_themeModel->select( m_preselectThemeId ); +} + +Calamares::JobList +Config::createJobs() const +{ + Calamares::JobList l; + + cDebug() << "Creating Plasma LNF jobs .."; + if ( !theme().isEmpty() ) + { + if ( !lnfToolPath().isEmpty() ) + { + l.append( Calamares::job_ptr( new PlasmaLnfJob( lnfToolPath(), theme() ) ) ); + } + else + { + cWarning() << "no lnftool given for plasmalnf module."; + } + } + return l; +} + +void +Config::setTheme( const QString& id ) +{ + if ( m_themeId == id ) + { + return; + } + + m_themeId = id; + if ( lnfToolPath().isEmpty() ) + { + cWarning() << "no lnftool given for plasmalnf module."; + } + else + { + QStringList command; + if ( !m_liveUser.isEmpty() ) + { + command << "sudo" + << "-E" + << "-H" + << "-u" << m_liveUser; + } + command << lnfToolPath() << "--resetLayout" + << "--apply" << id; + auto r = Calamares::System::instance()->runCommand( command, std::chrono::seconds( 10 ) ); + + if ( r.getExitCode() ) + { + cWarning() << "Failed (" << r.getExitCode() << ')'; + } + else + { + cDebug() << "Plasma look-and-feel applied" << id; + } + } + m_themeModel->select( id ); + emit themeChanged( id ); +} diff --git a/calamares/src/modules/plasmalnf/Config.h b/calamares/src/modules/plasmalnf/Config.h new file mode 100644 index 0000000..aafdf64 --- /dev/null +++ b/calamares/src/modules/plasmalnf/Config.h @@ -0,0 +1,77 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PLASMALNF_CONFIG_H +#define PLASMALNF_CONFIG_H + +#include "Job.h" + +#include "ThemeInfo.h" + +#include + +class Config : public QObject +{ + Q_OBJECT + + Q_PROPERTY( QString preselectedTheme READ preselectedTheme CONSTANT ) + Q_PROPERTY( QString theme READ theme WRITE setTheme NOTIFY themeChanged ) + Q_PROPERTY( QAbstractItemModel* themeModel READ themeModel CONSTANT ) + +public: + Config( QObject* parent = nullptr ); + virtual ~Config() override = default; // QObject cleans up the model pointer + + void setConfigurationMap( const QVariantMap& ); + Calamares::JobList createJobs() const; + + /** @brief Full path to the lookandfeeltool (if it exists) + * + * This can be configured, or defaults to `lookandfeeltool` to find + * it in $PATH. + */ + QString lnfToolPath() const { return m_lnfPath; } + /** @brief For OEM mode, the name of the (current) live user + * + */ + QString liveUser() const { return m_liveUser; } + + /** @brief The id (in reverse-DNS notation) of the current theme. + */ + QString theme() const { return m_themeId; } + + /** @brief The theme we start with + * + * This can be configured, or is taken from the live environment + * if the environment is (also) KDE Plasma. + */ + QString preselectedTheme() const { return m_preselectThemeId; } + + /** @brief The (list) model of available themes. + */ + QAbstractItemModel* themeModel() const { return m_filteredModel; } + +public slots: + void setTheme( const QString& id ); + +signals: + void themeChanged( const QString& id ); + +private: + QString m_lnfPath; // Path to the lnf tool + QString m_liveUser; // Name of the live user (for OEM mode) + + QString m_preselectThemeId; + QString m_themeId; // Id of selected theme + + QAbstractItemModel* m_filteredModel = nullptr; + ThemesModel* m_themeModel = nullptr; +}; + +#endif diff --git a/calamares/src/modules/plasmalnf/PlasmaLnfJob.cpp b/calamares/src/modules/plasmalnf/PlasmaLnfJob.cpp new file mode 100644 index 0000000..4672e34 --- /dev/null +++ b/calamares/src/modules/plasmalnf/PlasmaLnfJob.cpp @@ -0,0 +1,72 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PlasmaLnfJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#ifdef WITH_KCONFIG +#include +#include +#endif + +PlasmaLnfJob::PlasmaLnfJob( const QString& lnfPath, const QString& id ) + : m_lnfPath( lnfPath ) + , m_id( id ) +{ +} + +PlasmaLnfJob::~PlasmaLnfJob() {} + +QString +PlasmaLnfJob::prettyName() const +{ + return tr( "Applying Plasma Look-and-Feel…", "@status" ); +} + +Calamares::JobResult +PlasmaLnfJob::exec() +{ + auto* system = Calamares::System::instance(); + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + + QStringList command( { "sudo", + "-E", + "-H", + "-u", + gs->value( "username" ).toString(), + m_lnfPath, + "-platform", + "minimal", + "--resetLayout", + "--apply", + m_id } ); + + int r = system->targetEnvCall( command ); + if ( r ) + { + return Calamares::JobResult::error( tr( "Could not select KDE Plasma Look-and-Feel package" ), + tr( "Could not select KDE Plasma Look-and-Feel package" ) ); + } + +#ifdef WITH_KCONFIG + // This is a workaround for lookandfeeltool **not** writing + // the LookAndFeelPackage key in kdeglobals; this happens + // with the lnftool and Plasma 5.20 (possibly other combinations + // as well). + QString targetConfig = system->targetPath( "/home/" + gs->value( "username" ).toString() + "/.config/kdeglobals" ); + KConfigGroup cg( KSharedConfig::openConfig( targetConfig ), "KDE" ); + cg.writeEntry( "LookAndFeelPackage", m_id ); +#endif + + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/plasmalnf/PlasmaLnfJob.h b/calamares/src/modules/plasmalnf/PlasmaLnfJob.h new file mode 100644 index 0000000..4eaf810 --- /dev/null +++ b/calamares/src/modules/plasmalnf/PlasmaLnfJob.h @@ -0,0 +1,35 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PLASMALNFJOB_H +#define PLASMALNFJOB_H + +#include +#include + +#include "Job.h" + +class PlasmaLnfJob : public Calamares::Job +{ + Q_OBJECT + +public: + explicit PlasmaLnfJob( const QString& lnfPath, const QString& id ); + ~PlasmaLnfJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + +private: + QString m_lnfPath; + QString m_id; +}; + +#endif // PLASMALNFJOB_H diff --git a/calamares/src/modules/plasmalnf/PlasmaLnfPage.cpp b/calamares/src/modules/plasmalnf/PlasmaLnfPage.cpp new file mode 100644 index 0000000..3665058 --- /dev/null +++ b/calamares/src/modules/plasmalnf/PlasmaLnfPage.cpp @@ -0,0 +1,117 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "PlasmaLnfPage.h" + +#include "Config.h" +#include "ui_page_plasmalnf.h" + +#include "Settings.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" + +#include +#include +#include + +class ThemeDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const override; + // The size of the item is constant + QSize sizeHint( const QStyleOptionViewItem&, const QModelIndex& ) const override; +}; + +QSize +ThemeDelegate::sizeHint( const QStyleOptionViewItem&, const QModelIndex& ) const +{ + QSize image( ThemesModel::imageSize() ); + return { 3 * image.width(), image.height() }; +} + +void +ThemeDelegate::paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const +{ + auto label = index.data( ThemesModel::LabelRole ).toString(); + auto description = index.data( ThemesModel::DescriptionRole ).toString(); + auto selected = index.data( ThemesModel::SelectedRole ).toBool() ? QStyle::State_On : QStyle::State_Off; + auto image_v = index.data( ThemesModel::ImageRole ); + QPixmap image = image_v.canConvert< QPixmap >() ? qvariant_cast< QPixmap >( image_v ) : QPixmap(); + + // The delegate paints three "columns", each of which takes 1/3 + // of the space: label, description and screenshot. + QRect labelRect( option.rect ); + labelRect.setWidth( labelRect.width() / 3 ); + + QStyleOptionButton rbOption; + rbOption.state |= QStyle::State_Enabled | selected; + rbOption.rect = labelRect; + rbOption.text = label; + option.widget->style()->drawControl( QStyle::CE_RadioButton, &rbOption, painter, option.widget ); + + labelRect.moveLeft( labelRect.width() ); + option.widget->style()->drawItemText( + painter, labelRect, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextWordWrap, option.palette, false, description ); + + labelRect.moveLeft( 2 * labelRect.width() ); + option.widget->style()->drawItemPixmap( painter, labelRect, Qt::AlignHCenter | Qt::AlignVCenter, image ); +} + + +PlasmaLnfPage::PlasmaLnfPage( Config* config, QWidget* parent ) + : QWidget( parent ) + , ui( new Ui::PlasmaLnfPage ) + , m_config( config ) +{ + ui->setupUi( this ); + CALAMARES_RETRANSLATE( { + ui->retranslateUi( this ); + if ( Calamares::Settings::instance()->isSetupMode() ) + { + ui->generalExplanation->setText( tr( "Please choose a look-and-feel for the KDE Plasma Desktop. " + "You can also skip this step and configure the look-and-feel " + "once the system is set up. Clicking on a look-and-feel " + "selection will give you a live preview of that look-and-feel." ) ); + } + else + { + ui->generalExplanation->setText( tr( "Please choose a look-and-feel for the KDE Plasma Desktop. " + "You can also skip this step and configure the look-and-feel " + "once the system is installed. Clicking on a look-and-feel " + "selection will give you a live preview of that look-and-feel." ) ); + } + } ); + + auto* view = new QListView( this ); + view->setModel( m_config->themeModel() ); + view->setItemDelegate( new ThemeDelegate( view ) ); + view->setUniformItemSizes( true ); + view->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); + ui->verticalLayout->addWidget( view ); + + connect( view->selectionModel(), + &QItemSelectionModel::selectionChanged, + [ this ]( const QItemSelection& selected, const QItemSelection& ) + { + auto i = selected.indexes(); + if ( !i.isEmpty() ) + { + auto row = i.first().row(); + auto* model = m_config->themeModel(); + auto id = model->data( model->index( row, 0 ), ThemesModel::KeyRole ).toString(); + if ( !id.isEmpty() ) + { + m_config->setTheme( id ); + } + } + } ); +} diff --git a/calamares/src/modules/plasmalnf/PlasmaLnfPage.h b/calamares/src/modules/plasmalnf/PlasmaLnfPage.h new file mode 100644 index 0000000..2481323 --- /dev/null +++ b/calamares/src/modules/plasmalnf/PlasmaLnfPage.h @@ -0,0 +1,39 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PLASMALNFPAGE_H +#define PLASMALNFPAGE_H + +#include + +namespace Ui +{ +class PlasmaLnfPage; +} // namespace Ui + +class Config; + +/** @brief Page for selecting a Plasma Look-and-Feel theme. + * + * You must call setEnabledThemes -- either overload -- once + * to get the selection widgets. Note that calling that with + * an empty list will result in zero (0) selectable themes. + */ +class PlasmaLnfPage : public QWidget +{ + Q_OBJECT +public: + explicit PlasmaLnfPage( Config* config, QWidget* parent = nullptr ); + +private: + Ui::PlasmaLnfPage* ui; + Config* m_config; +}; + +#endif //PLASMALNFPAGE_H diff --git a/calamares/src/modules/plasmalnf/PlasmaLnfViewStep.cpp b/calamares/src/modules/plasmalnf/PlasmaLnfViewStep.cpp new file mode 100644 index 0000000..4f86758 --- /dev/null +++ b/calamares/src/modules/plasmalnf/PlasmaLnfViewStep.cpp @@ -0,0 +1,99 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "PlasmaLnfViewStep.h" + +#include "Config.h" +#include "PlasmaLnfPage.h" +#include "ThemeInfo.h" + +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include + +CALAMARES_PLUGIN_FACTORY_DEFINITION( PlasmaLnfViewStepFactory, registerPlugin< PlasmaLnfViewStep >(); ) + +PlasmaLnfViewStep::PlasmaLnfViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_config( new Config( this ) ) + , m_widget( new PlasmaLnfPage( m_config ) ) +{ + emit nextStatusChanged( false ); +} + + +PlasmaLnfViewStep::~PlasmaLnfViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +PlasmaLnfViewStep::prettyName() const +{ + return tr( "Look-and-Feel", "@label" ); +} + + +QWidget* +PlasmaLnfViewStep::widget() +{ + return m_widget; +} + + +bool +PlasmaLnfViewStep::isNextEnabled() const +{ + return true; +} + + +bool +PlasmaLnfViewStep::isBackEnabled() const +{ + return true; +} + + +bool +PlasmaLnfViewStep::isAtBeginning() const +{ + return true; +} + + +bool +PlasmaLnfViewStep::isAtEnd() const +{ + return true; +} + + +void +PlasmaLnfViewStep::onLeave() +{ +} + + +Calamares::JobList +PlasmaLnfViewStep::jobs() const +{ + return m_config->createJobs(); +} + + +void +PlasmaLnfViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); +} diff --git a/calamares/src/modules/plasmalnf/PlasmaLnfViewStep.h b/calamares/src/modules/plasmalnf/PlasmaLnfViewStep.h new file mode 100644 index 0000000..48f03cd --- /dev/null +++ b/calamares/src/modules/plasmalnf/PlasmaLnfViewStep.h @@ -0,0 +1,51 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PLASMALNFVIEWSTEP_H +#define PLASMALNFVIEWSTEP_H + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +class Config; +class PlasmaLnfPage; + +class PLUGINDLLEXPORT PlasmaLnfViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit PlasmaLnfViewStep( QObject* parent = nullptr ); + ~PlasmaLnfViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onLeave() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + Config* m_config; + PlasmaLnfPage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( PlasmaLnfViewStepFactory ) + +#endif // PLASMALNFVIEWSTEP_H diff --git a/calamares/src/modules/plasmalnf/ThemeInfo.cpp b/calamares/src/modules/plasmalnf/ThemeInfo.cpp new file mode 100644 index 0000000..96a9600 --- /dev/null +++ b/calamares/src/modules/plasmalnf/ThemeInfo.cpp @@ -0,0 +1,316 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +#include "ThemeInfo.h" + +#include "Branding.h" +#include "utils/Gui.h" +#include "utils/Logger.h" + +#include +#include + +#include +#include +#include + +/** @brief describes a single plasma LnF theme. + * + * A theme description has an id, which is really the name of the desktop + * file (e.g. org.kde.breeze.desktop), a name which is human-readable and + * translated, and an optional image Page, which points to a local screenshot + * of that theme. + */ +struct ThemeInfo +{ + QString id; + QString name; + QString description; + QString imagePath; + mutable QPixmap pixmap; + bool show = true; + bool selected = false; + + ThemeInfo() {} + + explicit ThemeInfo( const QString& _id ) + : id( _id ) + { + } + + explicit ThemeInfo( const QString& _id, const QString& image ) + : id( _id ) + , imagePath( image ) + { + } + + explicit ThemeInfo( const KPluginMetaData& ); + + bool isValid() const { return !id.isEmpty(); } + + /// @brief Fill in the pixmap member based on imagePath + QPixmap loadImage() const; +}; + +class ThemeInfoList : public QList< ThemeInfo > +{ +public: + std::pair< int, const ThemeInfo* > indexById( const QString& id ) const + { + int index = 0; + for ( const ThemeInfo& i : *this ) + { + if ( i.id == id ) + { + return { index, &i }; + } + } + return { -1, nullptr }; + } + + std::pair< int, ThemeInfo* > indexById( const QString& id ) + { + // Call the const version and then munge the types + auto [ i, p ] = const_cast< const ThemeInfoList* >( this )->indexById( id ); + return { i, const_cast< ThemeInfo* >( p ) }; + } + + /** @brief Looks for a given @p id in the list of themes, returns nullptr if not found. */ + ThemeInfo* findById( const QString& id ) + { + auto [ i, p ] = indexById( id ); + return p; + } + + /** @brief Looks for a given @p id in the list of themes, returns nullptr if not found. */ + const ThemeInfo* findById( const QString& id ) const + { + auto [ i, p ] = indexById( id ); + return p; + } + + /** @brief Checks if a given @p id is in the list of themes. */ + bool contains( const QString& id ) const { return findById( id ) != nullptr; } +}; + +ThemesModel::ThemesModel( QObject* parent ) + : QAbstractListModel( parent ) + , m_themes( new ThemeInfoList ) +{ + auto packages = KPackage::PackageLoader::self()->listPackages( "Plasma/LookAndFeel" ); + m_themes->reserve( packages.length() ); + + for ( const auto& p : packages ) + { + m_themes->append( ThemeInfo { p } ); + } +} + +int +ThemesModel::rowCount( const QModelIndex& ) const +{ + return m_themes->count(); +} + +QVariant +ThemesModel::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() ) + { + return QVariant(); + } + if ( index.row() < 0 || index.row() >= m_themes->count() ) + { + return QVariant(); + } + + const auto& item = m_themes->at( index.row() ); + switch ( role ) + { + case LabelRole: + return item.name; + case KeyRole: + return item.id; + case ShownRole: + return item.show; + case SelectedRole: + return item.selected; + case DescriptionRole: + return item.description; + case ImageRole: + return item.loadImage(); + default: + return QVariant(); + } + __builtin_unreachable(); +} + +QHash< int, QByteArray > +ThemesModel::roleNames() const +{ + return { { LabelRole, "label" }, + { KeyRole, "key" }, + { SelectedRole, "selected" }, + { ShownRole, "show" }, + { ImageRole, "image" } }; +} + +void +ThemesModel::setThemeImage( const QString& id, const QString& imagePath ) +{ + auto [ i, theme ] = m_themes->indexById( id ); + if ( theme ) + { + theme->imagePath = imagePath; + emit dataChanged( index( i, 0 ), index( i, 0 ), { ImageRole } ); + } +} + +void +ThemesModel::setThemeImage( const QMap< QString, QString >& images ) +{ + if ( m_themes->isEmpty() ) + { + return; + } + + // Don't emit signals from each call, aggregate to one call (below this block) + { + QSignalBlocker b( this ); + for ( auto k = images.constKeyValueBegin(); k != images.constKeyValueEnd(); ++k ) + { + setThemeImage( ( *k ).first, ( *k ).second ); + } + } + emit dataChanged( index( 0, 0 ), index( m_themes->count() - 1 ), { ImageRole } ); +} + +void +ThemesModel::showTheme( const QString& id, bool show ) +{ + auto [ i, theme ] = m_themes->indexById( id ); + if ( theme ) + { + theme->show = show; + emit dataChanged( index( i, 0 ), index( i, 0 ), { ShownRole } ); + } +} + +void +ThemesModel::showOnlyThemes( const QMap< QString, QString >& onlyThese ) +{ + if ( m_themes->isEmpty() ) + { + return; + } + + // No signal blocker block needed here because we're not calling showTheme() + // QSignalBlocker b( this ); + for ( auto& t : *m_themes ) + { + t.show = onlyThese.contains( t.id ); + } + emit dataChanged( index( 0, 0 ), index( m_themes->count() - 1 ), { ShownRole } ); +} + +QSize +ThemesModel::imageSize() +{ + return { qMax( 12 * Calamares::defaultFontHeight(), 120 ), qMax( 8 * Calamares::defaultFontHeight(), 80 ) }; +} + +void +ThemesModel::select( const QString& themeId ) +{ + int i = 0; + for ( auto& t : *m_themes ) + { + if ( t.selected && t.id != themeId ) + { + t.selected = false; + emit dataChanged( index( i, 0 ), index( i, 0 ), { SelectedRole } ); + } + if ( !t.selected && t.id == themeId ) + { + t.selected = true; + emit dataChanged( index( i, 0 ), index( i, 0 ), { SelectedRole } ); + } + ++i; + } +} + +/** + * Massage the given @p path to the most-likely + * path that actually contains a screenshot. For + * empty image paths, returns the QRC path for an + * empty screenshot. Returns blank if the path + * doesn't exist anywhere in the search paths. + */ +static QString +munge_imagepath( const QString& path ) +{ + if ( path.isEmpty() ) + { + return ":/view-preview.png"; + } + + if ( path.startsWith( '/' ) ) + { + return path; + } + + if ( QFileInfo::exists( path ) ) + { + return path; + } + + QFileInfo fi( QDir( Calamares::Branding::instance()->componentDirectory() ), path ); + if ( fi.exists() ) + { + return fi.absoluteFilePath(); + } + + return QString(); +} + +ThemeInfo::ThemeInfo( const KPluginMetaData& data ) + : id( data.pluginId() ) + , name( data.name() ) + , description( data.description() ) +{ +} + +QPixmap +ThemeInfo::loadImage() const +{ + if ( pixmap.isNull() ) + { + + const QSize image_size( ThemesModel::imageSize() ); + + const QString path = munge_imagepath( imagePath ); + cDebug() << "Loading initial image for" << id << imagePath << "->" << path; + QPixmap image( path ); + if ( image.isNull() ) + { + // Not found or not specified, so convert the name into some (horrible, likely) + // color instead. + image = QPixmap( image_size ); + auto hash_color = qHash( imagePath.isEmpty() ? id : imagePath ); + cDebug() << Logger::SubEntry << "Theme image" << imagePath << "not found, hash" << hash_color; + image.fill( QColor( QRgb( hash_color ) ) ); + } + else + { + cDebug() << Logger::SubEntry << "Theme image" << image.size(); + } + + pixmap = image.scaled( image_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation ); + } + return pixmap; +} diff --git a/calamares/src/modules/plasmalnf/ThemeInfo.h b/calamares/src/modules/plasmalnf/ThemeInfo.h new file mode 100644 index 0000000..c859bcd --- /dev/null +++ b/calamares/src/modules/plasmalnf/ThemeInfo.h @@ -0,0 +1,75 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PLASMALNF_THEMEINFO_H +#define PLASMALNF_THEMEINFO_H + +#include +#include +#include + +class ThemeInfoList; + +class ThemesModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum + { + LabelRole = Qt::DisplayRole, + KeyRole = Qt::UserRole, + ShownRole, // Should theme be displayed + SelectedRole, // Is theme selected + DescriptionRole, + ImageRole + }; + + explicit ThemesModel( QObject* parent ); + + int rowCount( const QModelIndex& = QModelIndex() ) const override; + QVariant data( const QModelIndex& index, int role ) const override; + + QHash< int, QByteArray > roleNames() const override; + + /// @brief Set the screenshot to go with the given @p id + void setThemeImage( const QString& id, const QString& imagePath ); + + /// @brief Call setThemeImage( key, value ) for all keys in @p images + void setThemeImage( const QMap< QString, QString >& images ); + + /// @brief Set whether to show the given theme @p id (or not) + void showTheme( const QString& id, bool show = true ); + + /// @brief Shows the keys in the @p onlyThese map, and hides the rest + void showOnlyThemes( const QMap< QString, QString >& onlyThese ); + + /** @brief Mark the @p themeId as current / selected + * + * One theme can be selected at a time; this will emit data + * changed signals for any (one) theme already selected, and + * the newly-selected theme. If @p themeId does not name any + * theme, none are selected. + */ + void select( const QString& themeId ); + + /** @brief The size of theme Images + * + * The size is dependent on the font size used by Calamares, + * and is constant within one run of Calamares, but may change + * if the font settings do between runs. + */ + static QSize imageSize(); + +private: + ThemeInfoList* m_themes; +}; + + +#endif diff --git a/calamares/src/modules/plasmalnf/page_plasmalnf.qrc b/calamares/src/modules/plasmalnf/page_plasmalnf.qrc new file mode 100644 index 0000000..c63ecc0 --- /dev/null +++ b/calamares/src/modules/plasmalnf/page_plasmalnf.qrc @@ -0,0 +1,5 @@ + + + view-preview.png + + diff --git a/calamares/src/modules/plasmalnf/page_plasmalnf.ui b/calamares/src/modules/plasmalnf/page_plasmalnf.ui new file mode 100644 index 0000000..c47bc6c --- /dev/null +++ b/calamares/src/modules/plasmalnf/page_plasmalnf.ui @@ -0,0 +1,37 @@ + + + +SPDX-FileCopyrightText: 2017 Adriaan de Groot <groot@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + PlasmaLnfPage + + + + 0 + 0 + 799 + 400 + + + + Form + + + + + + Placeholder + + + true + + + + + + + + + + diff --git a/calamares/src/modules/plasmalnf/plasmalnf.conf b/calamares/src/modules/plasmalnf/plasmalnf.conf new file mode 100644 index 0000000..105f247 --- /dev/null +++ b/calamares/src/modules/plasmalnf/plasmalnf.conf @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# The Plasma Look-and-Feel module allows selecting a Plasma +# Look-and-Feel in the live- or host-system and switches the +# host Plasma session immediately to the chosen LnF; it +# can also write a LnF configuration to the target user / on +# the target system. +# +# This module should be used once in a view section (to get +# the UI) and once in the exec section (to apply the selection +# to the target user). It should come **after** the user module +# in exec, so that the target user has been created alrady. +--- +# Full path to the Plasma look-and-feel tool (CLI program +# for querying and applying Plasma themes). If this is not +# set, no LNF setting will happen. +lnftool: "/usr/bin/lookandfeeltool" + +# For systems where the user Calamares runs as (usually root, +# via either sudo or pkexec) has a clean environment, set this +# to the originating username; the lnftool will be run through +# "sudo -H -u " instead of directly. +# +# liveuser: "live" + +# If *showAll* is true, then all installed themes are shown in the +# UI for selection, even if they are not listed in *themes* (below). +# This allows selection of all themes even while not all of them are +# listed in *themes* -- which is useful to show screenshots for those +# you do have a screenshot for. If *themes* is empty or missing, +# the value of *showAll* is treated as `true`. +showAll: false + +# You can limit the list of Plasma look-and-feel themes by listing ids +# here. If this key is not present, all of the installed themes are listed. +# If the key is present, only installed themes that are **also** included +# in the list are shown (could be none!). See the *showAll* key, above, +# to change that. +# +# Themes may be listed by id, (e.g. fluffy-bunny, below) or as a theme +# and an image (e.g. breeze) which will be used to show a screenshot. +# Themes with no image set at all get a "missing screenshot" image; if the +# image file is not found, they get a color swatch based on the image name. +# +# The image may be an absolute path. If it is a relative path, though, +# it is searched in the current directory and in the branding directory +# (i.e. relative to the directory where your branding.desc lives). +# +# Valid forms of entries in the *themes* key: +# - A single string (unquoted), which is the theme id +# - A pair of *theme* and *image* keys, e.g. +# ``` +# - theme: fluffy-bunny.desktop +# image: "fluffy-screenshot.png" +# ``` +# +# The image screenshot is resized to 12x8 the current font size, with +# a minimum of 120x80 pixels. This allows the screenshot to scale up +# on HiDPI displays where the fonts are larger (in pixels). +themes: + - org.kde.fuzzy-pig.desktop + - theme: org.kde.breeze.desktop + image: "breeze.png" + - theme: org.kde.breezedark.desktop + image: "breeze-dark.png" + - org.kde.fluffy-bunny.desktop + +# You can pre-select one of the themes; it is not applied +# immediately, but its radio-button is switched on to indicate +# that that is the theme (that is most likely) currently in use. +# Do this only on Live images where you are reasonably sure +# that the user is not going to change the theme out from under +# themselves before running the installer. +# +# If this key is present, its value should be the id of the theme +# which should be pre-selected. If absent, empty, or the pre-selected +# theme is not found on the live system, no theme will be pre-selected. +# +# As a special setting, use "*", to try to find the currently- +# selected theme by reading the Plasma configuration. This requires +# KF5::Config at build- and run-time. +preselect: "*" diff --git a/calamares/src/modules/plasmalnf/view-preview.png b/calamares/src/modules/plasmalnf/view-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..8e5f07ba9018e2708d9825b2446ea3ae5cc98d91 GIT binary patch literal 560 zcmeAS@N?(olHy`uVBq!ia0vp^6+j%o!3HER&ED7vq&N#aB8wRq_>O=u<5X=vX$A(y zN1iT@Ar*7p-tt|w*g>N8;bmXvV@=ituaqXSN*pXyTX(QAx`X+k>=iyHwUjw;9H&2I znl#atWBbmRe@gO-w@y0s+owgSi=TgfsF)Yf3N&y)q3(v>18bMSEBu!K(>7P$eE#Kh z8RPxby>a|T?`!PjQ|@j2vn7_H?WqrY{k77$J%(Ag!zPKeExcqh_5JT@+S2ojW$%>E z-J}yNz_MzUS7@jv2UBX~vURU>rkh(mXEHmc(U+|1sdWDHlgfFMR03s=8y&l$mtS&@ z_0G1nX`62z{ZV4|_w%Vb^>eH}#phUM+7_1WzI*gX-TrWiHZiSDr+3Dd%vyG8lh37> zTXp6hTKhUGH8N_=8NTOC-)iGiBTq>z-nCA3?yg-rr)SUiJpMRv^2wAlMju53MZW%D zojI#+W5gHV%Q1WHzZkx_Fekcs|DxPzQP+hko*^Pfqu*_cKcp;MV and others +SPDX-License-Identifier: LGPL-3.0-or-later diff --git a/calamares/src/modules/plasmalnf/view-preview.svg b/calamares/src/modules/plasmalnf/view-preview.svg new file mode 100644 index 0000000..90e5bee --- /dev/null +++ b/calamares/src/modules/plasmalnf/view-preview.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/calamares/src/modules/plasmalnf/view-preview.svg.license b/calamares/src/modules/plasmalnf/view-preview.svg.license new file mode 100644 index 0000000..ef0e9d7 --- /dev/null +++ b/calamares/src/modules/plasmalnf/view-preview.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2014 Uri Herrera and others +SPDX-License-Identifier: LGPL-3.0-or-later diff --git a/calamares/src/modules/plymouthcfg/main.py b/calamares/src/modules/plymouthcfg/main.py new file mode 100644 index 0000000..529d342 --- /dev/null +++ b/calamares/src/modules/plymouthcfg/main.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2016 Artoo +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2018 Gabriel Craciunescu +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import libcalamares + +from libcalamares.utils import debug, target_env_call + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Configure Plymouth theme") + + +def detect_plymouth(): + """ + Checks existence (runnability) of plymouth in the target system. + + @return True if plymouth exists in the target, False otherwise + """ + # Used to only check existence of path /usr/bin/plymouth in target + return target_env_call(["sh", "-c", "which plymouth"]) == 0 + + +class PlymouthController: + + def __init__(self): + self.__root = libcalamares.globalstorage.value('rootMountPoint') + + @property + def root(self): + return self.__root + + def setTheme(self): + plymouth_theme = libcalamares.job.configuration["plymouth_theme"] + target_env_call(["plymouth-set-default-theme", plymouth_theme]) + + def run(self): + if detect_plymouth(): + if (("plymouth_theme" in libcalamares.job.configuration) and + (libcalamares.job.configuration["plymouth_theme"] is not None)): + self.setTheme() + return None + + +def run(): + pc = PlymouthController() + return pc.run() diff --git a/calamares/src/modules/plymouthcfg/module.desc b/calamares/src/modules/plymouthcfg/module.desc new file mode 100644 index 0000000..660aa71 --- /dev/null +++ b/calamares/src/modules/plymouthcfg/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "plymouthcfg" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/plymouthcfg/plymouthcfg.conf b/calamares/src/modules/plymouthcfg/plymouthcfg.conf new file mode 100644 index 0000000..ebe51d1 --- /dev/null +++ b/calamares/src/modules/plymouthcfg/plymouthcfg.conf @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Plymouth Configuration Module +# +# This module can be used to setup the default plymouth theme to +# be used with your distribution +# +# You should only use this module if the plymouth package is installed +# on the build configurations of your distribution & the plymouth +# theme you want to configure is installed as well. If the unpacked +# filesystem configures a plymouth theme already, there is no need +# to change it here. +--- + + +# Leave this commented if you want to use the default theme +# shipped with your distribution configurations. Make sure that +# the theme exists in the themes directory of plymouth path. +# Debian / Ubuntu comes with themes "joy", "script", "softwaves", +# possibly others. Look in /usr/share/plymouth/themes for more. +# +# Specifying a non-existent theme will leave the plymouth +# configuration set to that theme. It is up to plymouth to +# deal with that. + +plymouth_theme: spinfinity + + + + diff --git a/calamares/src/modules/plymouthcfg/plymouthcfg.schema.yaml b/calamares/src/modules/plymouthcfg/plymouthcfg.schema.yaml new file mode 100644 index 0000000..27925ec --- /dev/null +++ b/calamares/src/modules/plymouthcfg/plymouthcfg.schema.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/plymouthcfg +additionalProperties: false +type: object +properties: + plymouth_theme: { type: string } diff --git a/calamares/src/modules/preservefiles/CMakeLists.txt b/calamares/src/modules/preservefiles/CMakeLists.txt new file mode 100644 index 0000000..b11b131 --- /dev/null +++ b/calamares/src/modules/preservefiles/CMakeLists.txt @@ -0,0 +1,17 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(preservefiles + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Item.cpp + PreserveFiles.cpp + # REQUIRES mount # To set the rootMountPoint + SHARED_LIB + EMERGENCY +) + +calamares_add_test(preservefilestest SOURCES Item.cpp Tests.cpp) diff --git a/calamares/src/modules/preservefiles/Item.cpp b/calamares/src/modules/preservefiles/Item.cpp new file mode 100644 index 0000000..7e8c28e --- /dev/null +++ b/calamares/src/modules/preservefiles/Item.cpp @@ -0,0 +1,159 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018, 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "Item.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Units.h" +#include "utils/Variant.h" + +#include + +using namespace Calamares::Units; + +static bool +copy_file( const QString& source, const QString& dest ) +{ + QFile sourcef( source ); + if ( !sourcef.open( QFile::ReadOnly ) ) + { + cWarning() << "Could not read" << source; + return false; + } + + QFile destf( dest ); + if ( !destf.open( QFile::WriteOnly ) ) + { + sourcef.close(); + cWarning() << "Could not open" << destf.fileName() << "for writing; could not copy" << source; + return false; + } + + QByteArray b; + do + { + b = sourcef.read( 1_MiB ); + destf.write( b ); + } while ( b.size() > 0 ); + + sourcef.close(); + destf.close(); + + return true; +} + +Item +Item::fromVariant( const QVariant& v, const Calamares::Permissions& defaultPermissions ) +{ + if ( Calamares::typeOf( v ) == Calamares::StringVariantType ) + { + QString filename = v.toString(); + if ( !filename.isEmpty() ) + { + return { filename, filename, defaultPermissions, ItemType::Path, false }; + } + else + { + cWarning() << "Empty filename for preservefiles, item" << v; + return {}; + } + } + else if ( Calamares::typeOf( v ) == Calamares::MapVariantType ) + { + const auto map = v.toMap(); + + Calamares::Permissions perm( defaultPermissions ); + ItemType t = ItemType::None; + bool optional = Calamares::getBool( map, "optional", false ); + + { + QString perm_string = map[ "perm" ].toString(); + if ( !perm_string.isEmpty() ) + { + perm = Calamares::Permissions( perm_string ); + } + } + + { + QString from = map[ "from" ].toString(); + t = ( from == "log" ) ? ItemType::Log : ( from == "config" ) ? ItemType::Config : ItemType::None; + + if ( t == ItemType::None && !map[ "src" ].toString().isEmpty() ) + { + t = ItemType::Path; + } + } + + QString dest = map[ "dest" ].toString(); + if ( dest.isEmpty() ) + { + cWarning() << "Empty dest for preservefiles, item" << v; + return {}; + } + + switch ( t ) + { + case ItemType::Config: + return { QString(), dest, perm, t, optional }; + case ItemType::Log: + return { QString(), dest, perm, t, optional }; + case ItemType::Path: + return { map[ "src" ].toString(), dest, perm, t, optional }; + case ItemType::None: + cWarning() << "Invalid type for preservefiles, item" << v; + return {}; + } + } + cWarning() << "Invalid type for preservefiles, item" << v; + return {}; +} + +bool +Item::exec( const std::function< QString( QString ) >& replacements ) const +{ + QString expanded_dest = replacements( dest ); + QString full_dest = Calamares::System::instance()->targetPath( expanded_dest ); + + bool success = false; + switch ( m_type ) + { + case ItemType::None: + cWarning() << "Invalid item for preservefiles skipped."; + return false; + case ItemType::Config: + if ( !( success = Calamares::JobQueue::instance()->globalStorage()->saveJson( full_dest ) ) ) + { + cWarning() << "Could not write a JSON dump of global storage to" << full_dest; + } + break; + case ItemType::Log: + if ( !( success = copy_file( Logger::logFile(), full_dest ) ) ) + { + cWarning() << "Could not preserve log file to" << full_dest; + } + break; + case ItemType::Path: + if ( !( success = copy_file( source, full_dest ) ) ) + { + cWarning() << "Could not preserve" << source << "to" << full_dest; + } + break; + } + if ( !success ) + { + Calamares::System::instance()->removeTargetFile( expanded_dest ); + return false; + } + else + { + return perm.apply( full_dest ); + } +} diff --git a/calamares/src/modules/preservefiles/Item.h b/calamares/src/modules/preservefiles/Item.h new file mode 100644 index 0000000..8707d8d --- /dev/null +++ b/calamares/src/modules/preservefiles/Item.h @@ -0,0 +1,74 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018, 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ +#ifndef PRESERVEFILES_ITEM_H +#define PRESERVEFILES_ITEM_H + +#include "utils/Permissions.h" + +#include +#include + +#include + +enum class ItemType +{ + None, + Path, + Log, + Config +}; + +/** @brief Represents one item to copy + * + * All item types need a destination (to place the data), this is + * intepreted within the target system. All items need a permission, + * which is applied to the data once written. + * + * The source may be a path, but not all types need a source. + */ +class Item +{ + QString source; + QString dest; + Calamares::Permissions perm; + ItemType m_type = ItemType::None; + bool m_optional = false; + +public: + Item( const QString& src, const QString& d, Calamares::Permissions p, ItemType t, bool optional ) + : source( src ) + , dest( d ) + , perm( std::move( p ) ) + , m_type( t ) + , m_optional( optional ) + { + } + + Item() + : m_type( ItemType::None ) + { + } + + operator bool() const { return m_type != ItemType::None; } + ItemType type() const { return m_type; } + bool isOptional() const { return m_optional; } + + bool exec( const std::function< QString( QString ) >& replacements ) const; + + /** @brief Create an Item -- or one of its subclasses -- from @p v + * + * Depending on the structure and contents of @p v, a pointer + * to an Item is returned. If @p v cannot be interpreted meaningfully, + * then a nullptr is returned. + * + * When the entry contains a *perm* key, use that permission, otherwise + * apply @p defaultPermissions to the item. + */ + static Item fromVariant( const QVariant& v, const Calamares::Permissions& defaultPermissions ); +}; + +#endif diff --git a/calamares/src/modules/preservefiles/PreserveFiles.cpp b/calamares/src/modules/preservefiles/PreserveFiles.cpp new file mode 100644 index 0000000..8419d4c --- /dev/null +++ b/calamares/src/modules/preservefiles/PreserveFiles.cpp @@ -0,0 +1,120 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#include "PreserveFiles.h" + +#include "Item.h" + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" +#include "utils/CommandList.h" +#include "utils/Logger.h" +#include "utils/StringExpander.h" +#include "utils/System.h" +#include "utils/Units.h" + +#include + +using namespace Calamares::Units; + +QString +atReplacements( QString s ) +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + QString root( "/" ); + QString user; + + if ( gs && gs->contains( "rootMountPoint" ) ) + { + root = gs->value( "rootMountPoint" ).toString(); + } + if ( gs && gs->contains( "username" ) ) + { + user = gs->value( "username" ).toString(); + } + + Calamares::String::DictionaryExpander d; + return d.add( QStringLiteral( "ROOT" ), root ).add( QStringLiteral( "USER" ), user ).expand( s ); +} + +PreserveFiles::PreserveFiles( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +PreserveFiles::~PreserveFiles() {} + +QString +PreserveFiles::prettyName() const +{ + return tr( "Saving files for later…", "@status" ); +} + +Calamares::JobResult +PreserveFiles::exec() +{ + if ( m_items.empty() ) + { + return Calamares::JobResult::error( tr( "No files configured to save for later." ) ); + } + + int count = 0; + for ( const auto& it : std::as_const( m_items ) ) + { + if ( !it ) + { + // Invalid entries are nullptr, ignore them but count as a success + // because they shouldn't block the installation. There are + // warnings in the log showing what the configuration problem is. + ++count; + continue; + } + // Try to preserve the file. If it's marked as optional, count it + // as a success regardless. + if ( it.exec( atReplacements ) || it.isOptional() ) + { + ++count; + } + } + + return count == m_items.size() + ? Calamares::JobResult::ok() + : Calamares::JobResult::error( tr( "Not all of the configured files could be preserved." ) ); +} + +void +PreserveFiles::setConfigurationMap( const QVariantMap& configurationMap ) +{ + auto files = configurationMap[ "files" ]; + if ( !files.isValid() ) + { + cDebug() << "No 'files' key for preservefiles."; + return; + } + + if ( Calamares::typeOf( files ) != Calamares::ListVariantType ) + { + cDebug() << "Configuration key 'files' is not a list for preservefiles."; + return; + } + + QString defaultPermissions = configurationMap[ "perm" ].toString(); + if ( defaultPermissions.isEmpty() ) + { + defaultPermissions = QStringLiteral( "root:root:0400" ); + } + Calamares::Permissions perm( defaultPermissions ); + + for ( const auto& li : files.toList() ) + { + m_items.push_back( Item::fromVariant( li, perm ) ); + } +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( PreserveFilesFactory, registerPlugin< PreserveFiles >(); ) diff --git a/calamares/src/modules/preservefiles/PreserveFiles.h b/calamares/src/modules/preservefiles/PreserveFiles.h new file mode 100644 index 0000000..dfd2804 --- /dev/null +++ b/calamares/src/modules/preservefiles/PreserveFiles.h @@ -0,0 +1,39 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + */ + +#ifndef PRESERVEFILES_H +#define PRESERVEFILES_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +class Item; + +class PLUGINDLLEXPORT PreserveFiles : public Calamares::CppJob +{ + Q_OBJECT + + using ItemList = QList< Item >; + +public: + explicit PreserveFiles( QObject* parent = nullptr ); + ~PreserveFiles() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + ItemList m_items; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( PreserveFilesFactory ) + +#endif // PRESERVEFILES_H diff --git a/calamares/src/modules/preservefiles/Tests.cpp b/calamares/src/modules/preservefiles/Tests.cpp new file mode 100644 index 0000000..645843b --- /dev/null +++ b/calamares/src/modules/preservefiles/Tests.cpp @@ -0,0 +1,93 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Item.h" + +#include "Settings.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include + +class PreserveFilesTests : public QObject +{ + Q_OBJECT +public: + PreserveFilesTests(); + ~PreserveFilesTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testItems_data(); + void testItems(); +}; + +PreserveFilesTests::PreserveFilesTests() {} + +void +PreserveFilesTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "PreserveFiles test started."; + + // Ensure we have a system object, expect it to be a "bogus" one + Calamares::System* system = Calamares::System::instance(); + QVERIFY( system ); + cDebug() << Logger::SubEntry << "System @" << Logger::Pointer( system ); + + const auto* settings = Calamares::Settings::instance(); + if ( !settings ) + { + (void)new Calamares::Settings( true ); + } +} + +void +PreserveFilesTests::testItems_data() +{ + QTest::addColumn< QString >( "filename" ); + QTest::addColumn< bool >( "ok" ); + QTest::addColumn< int >( "type_i" ); + + QTest::newRow( "log " ) << QString( "1a-log.conf" ) << true << smash( ItemType::Log ); + QTest::newRow( "config " ) << QString( "1b-config.conf" ) << true << smash( ItemType::Config ); + QTest::newRow( "src " ) << QString( "1c-src.conf" ) << true << smash( ItemType::Path ); + QTest::newRow( "filename" ) << QString( "1d-filename.conf" ) << true << smash( ItemType::Path ); + QTest::newRow( "empty " ) << QString( "1e-empty.conf" ) << false << smash( ItemType::None ); + QTest::newRow( "bad " ) << QString( "1f-bad.conf" ) << false << smash( ItemType::None ); +} + +void +PreserveFilesTests::testItems() +{ + QFETCH( QString, filename ); + QFETCH( bool, ok ); + QFETCH( int, type_i ); + + QFileInfo fi( QString( "%1/tests/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool config_file_ok = false; + const auto map = Calamares::YAML::load( fi, &config_file_ok ); + QVERIFY( config_file_ok ); + + Calamares::Permissions perm( QStringLiteral( "adridg:adridg:0750" ) ); + auto i = Item::fromVariant( map[ "item" ], perm ); + QCOMPARE( bool( i ), ok ); + QCOMPARE( smash( i.type() ), type_i ); +} + +QTEST_GUILESS_MAIN( PreserveFilesTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/preservefiles/preservefiles.conf b/calamares/src/modules/preservefiles/preservefiles.conf new file mode 100644 index 0000000..75584f5 --- /dev/null +++ b/calamares/src/modules/preservefiles/preservefiles.conf @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the preserve-files job +# +# The *files* key contains a list of files to preserve. Each element of +# the list should have one of these forms: +# +# - an absolute path (probably within the host system). This will be preserved +# as the same path within the target system (chroot). If, globally, +# *dontChroot* is true, then these items will be ignored (since the +# destination is the same as the source). +# - a map with a *dest* key. The *dest* value is a path interpreted in the +# target system (if the global *dontChroot* is true, then the host is the +# target as well). Relative paths are not recommended. There are two +# ways to select the source data for the file: +# - *from*, which must have one of the values, below; it is used to +# preserve files whose pathname is known to Calamares internally. +# - *src*, to refer to a path interpreted in the host system. Relative +# paths are not recommended, and are interpreted relative to where +# Calamares is being run. +# Exactly one of the two source keys (either *from* or *src*) must be set. +# +# Special values for the key *from* are: +# - *log*, for the complete log file (up to the moment the preservefiles +# module is run), +# - *config*, for a JSON dump of the contents of global storage. +# Note that this may contain sensitive information, and should be +# given restrictive permissions. +# +# A map with a *dest* key can have these additional fields: +# - *perm*, is a colon-separated tuple of :: +# where is in octal (e.g. 4777 for wide-open, 0400 for read-only +# by owner). If set, the file's ownership and permissions are set to +# those values within the target system; if not set, no permissions +# are changed. +# - *optional*, is a boolean; if this is set to `true` then failure to +# preserve the file will **not** be counted as a failure of the +# module, and installation will proceed. Set this for files that might +# not exist in the host system (e.g. nvidia configuration files that +# are created in some boot scenarios and not in others). +# +# The target path (*dest*) is modified by expanding variables in `${}`: +# - `ROOT` is replaced by the path to the target root (may be /). +# There is never any reason to use this, since the *dest* is already +# interpreted in the target system. +# - `USER` is replaced by the username entered by on the user +# page (may be empty, for instance if no user page is enabled) +# +# +# +files: + - from: log + dest: /var/log/Calamares.log + perm: root:wheel:600 + - from: log + dest: /home/${USER}/installation.log + optional: true + - from: config + dest: /var/log/Calamares-install.json + perm: root:wheel:600 +# - src: /var/log/nvidia.conf +# dest: /var/log/Calamares-nvidia.conf +# optional: true + +# The *perm* key contains a default value to apply to all files listed +# above that do not have a *perm* key of their own. If not set, +# root:root:0400 (highly restrictive) is used. +# +# perm: "root:root:0400" diff --git a/calamares/src/modules/preservefiles/preservefiles.schema.yaml b/calamares/src/modules/preservefiles/preservefiles.schema.yaml new file mode 100644 index 0000000..65067ea --- /dev/null +++ b/calamares/src/modules/preservefiles/preservefiles.schema.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/preservefiles +additionalProperties: false +type: object +properties: + # TODO: it's a particularly-formatted string + perm: { type: string } + files: + type: array + items: + # There are three entries here because: string, or an entry with + # a src (but no from) or an entry with from (but no src). + anyOf: + - type: string + - type: object + properties: + dest: { type: string } + src: { type: string } + # TODO: it's a particularly-formatted string + perm: { type: string } + optional: { type: boolean } + required: [ dest ] + additionalProperties: false + - type: object + properties: + dest: { type: string } + from: { type: string, enum: [config, log] } + # TODO: it's a particularly-formatted string + perm: { type: string } + optional: { type: boolean } + required: [ dest ] + additionalProperties: false + +required: [ files ] diff --git a/calamares/src/modules/preservefiles/tests/1a-log.conf b/calamares/src/modules/preservefiles/tests/1a-log.conf new file mode 100644 index 0000000..d589d4d --- /dev/null +++ b/calamares/src/modules/preservefiles/tests/1a-log.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +item: + from: log + dest: /var/log/Calamares.log + perm: root:wheel:601 diff --git a/calamares/src/modules/preservefiles/tests/1b-config.conf b/calamares/src/modules/preservefiles/tests/1b-config.conf new file mode 100644 index 0000000..409dc89 --- /dev/null +++ b/calamares/src/modules/preservefiles/tests/1b-config.conf @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +item: + from: config + dest: /var/log/Calamares-install.json + perm: root:wheel:600 diff --git a/calamares/src/modules/preservefiles/tests/1c-src.conf b/calamares/src/modules/preservefiles/tests/1c-src.conf new file mode 100644 index 0000000..130ddd0 --- /dev/null +++ b/calamares/src/modules/preservefiles/tests/1c-src.conf @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +item: + src: /root/.cache/calamares/session.log + dest: /var/log/Calamares.log + perm: root:wheel:600 diff --git a/calamares/src/modules/preservefiles/tests/1d-filename.conf b/calamares/src/modules/preservefiles/tests/1d-filename.conf new file mode 100644 index 0000000..130ddd0 --- /dev/null +++ b/calamares/src/modules/preservefiles/tests/1d-filename.conf @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +item: + src: /root/.cache/calamares/session.log + dest: /var/log/Calamares.log + perm: root:wheel:600 diff --git a/calamares/src/modules/preservefiles/tests/1e-empty.conf b/calamares/src/modules/preservefiles/tests/1e-empty.conf new file mode 100644 index 0000000..183d4e4 --- /dev/null +++ b/calamares/src/modules/preservefiles/tests/1e-empty.conf @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +item: [] diff --git a/calamares/src/modules/preservefiles/tests/1f-bad.conf b/calamares/src/modules/preservefiles/tests/1f-bad.conf new file mode 100644 index 0000000..b2c0089 --- /dev/null +++ b/calamares/src/modules/preservefiles/tests/1f-bad.conf @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +item: + bop: 1 diff --git a/calamares/src/modules/rawfs/main.py b/calamares/src/modules/rawfs/main.py new file mode 100644 index 0000000..7604982 --- /dev/null +++ b/calamares/src/modules/rawfs/main.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2019 Collabora Ltd +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import libcalamares +import os +import stat +import subprocess +from time import gmtime, strftime, sleep +from math import gcd + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + +def pretty_name(): + return _("Installing data.") + +def lcm(a, b): + """ + Computes the Least Common Multiple of 2 numbers + """ + return a * b / gcd(a, b) + +def get_device_size(device): + """ + Returns a filesystem's total size and block size in bytes. + For block devices, block size is the device's block size. + For other files (fs images), block size is 1 byte. + + @param device: str + Absolute path to the device or filesystem image. + @return: tuple(int, int) + The filesystem's size and its block size. + """ + mode = os.stat(device).st_mode + if stat.S_ISBLK(mode): + basedevice = "" + partition = os.path.basename(device) + tmp = partition + while len(tmp) > 0: + tmp = tmp[:-1] + if os.path.exists("/sys/block/" + tmp): + basedevice = tmp + break + # Get device block size + file = open("/sys/block/" + basedevice + "/queue/hw_sector_size") + blocksize = int(file.readline()) + file.close() + # Get partition size + file = open("/sys/block/" + basedevice + "/" + partition + "/size") + size = int(file.readline()) * blocksize + file.close() + else: + size = os.path.getsize(device) + blocksize = 1 + + return size, blocksize + +class RawFSLowSpaceError(Exception): + pass + +class RawFSItem: + __slots__ = ['source', 'destination', 'filesystem', 'resize'] + + def copy(self, current=0, total=1): + """ + Copies a raw filesystem on a disk partition, and grow it to the full destination + partition's size if required. + + @param current: int + The index of the current item in the filesystems list + (used for progress reporting) + @param total: int + The number of items in the filesystems list + (used for progress reporting) + """ + count = 0 + + libcalamares.utils.debug("Copying {} to {}".format(self.source, self.destination)) + if libcalamares.job.configuration.get("bogus", False): + return + + srcsize, srcblksize = get_device_size(self.source) + destsize, destblksize = get_device_size(self.destination) + + if destsize < srcsize: + raise RawFSLowSpaceError + return + + # Compute transfer block size (100x the LCM of the block sizes seems a good fit) + blksize = int(100 * lcm(srcblksize, destblksize)) + + # Execute copy + src = open(self.source, "rb") + dest = open(self.destination, "wb") + buffer = src.read(blksize) + while len(buffer) > 0: + dest.write(buffer) + count += len(buffer) + # Compute job progress + progress = ((count / srcsize) + (current)) / total + libcalamares.job.setprogress(progress) + # Read next data block + buffer = src.read(blksize) + src.close() + dest.close() + + if self.resize: + if "ext" in self.filesystem: + libcalamares.utils.debug("Resizing filesystem on {}".format(self.destination)) + subprocess.run(["e2fsck", "-f", "-y", self.destination]) + subprocess.run(["resize2fs", self.destination]) + + def __init__(self, config, device, fs): + libcalamares.utils.debug("Adding an entry for raw copy of {} to {}".format( + config["source"], device)) + self.source = os.path.realpath(config["source"]) + # If source is a mount point, look for the actual device mounted on it + if os.path.ismount(self.source) and not libcalamares.job.configuration.get("bogus", False): + procmounts = open("/proc/mounts", "r") + for line in procmounts: + if self.source in line.split(): + self.source = line.split()[0] + break + + self.destination = device + self.filesystem = fs + try: + self.resize = bool(config["resize"]) + except KeyError: + self.resize = False + +def update_global_storage(item, gs): + for partition in gs: + if partition["device"] == item.destination: + ret = subprocess.run(["blkid", "-s", "UUID", "-o", "value", item.destination], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if ret.returncode == 0: + uuid = ret.stdout.rstrip() + libcalamares.utils.debug("Setting {} UUID to {}".format(item.destination, + uuid or "")) + gs[gs.index(partition)]["uuid"] = uuid + gs[gs.index(partition)]["source"] = item.source + + libcalamares.globalstorage.remove("partitions") + libcalamares.globalstorage.insert("partitions", gs) + +def run(): + """Raw filesystem copy module""" + filesystems = list() + partitions = libcalamares.globalstorage.value("partitions") + + if not partitions: + libcalamares.utils.warning("partitions is empty, {!s}".format(partitions)) + return (_("Configuration Error"), + _("No partitions are defined for
    {!s}
    to use." ).format("rawfs")) + + libcalamares.utils.debug("Copying {!s} raw partitions.".format(len(partitions))) + for partition in partitions: + if partition["mountPoint"]: + for src in libcalamares.job.configuration["targets"]: + if src["mountPoint"] == partition["mountPoint"]: + filesystems.append(RawFSItem(src, partition["device"], partition["fs"])) + + for item in filesystems: + try: + item.copy(filesystems.index(item), len(filesystems)) + except RawFSLowSpaceError: + return ("Not enough free space", + "{} partition is too small to copy {} on it".format(item.destination, item.source)) + update_global_storage(item, partitions) + + return None diff --git a/calamares/src/modules/rawfs/module.desc b/calamares/src/modules/rawfs/module.desc new file mode 100644 index 0000000..0c4f21f --- /dev/null +++ b/calamares/src/modules/rawfs/module.desc @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# Module metadata file for block-copy jobmodule +# Syntax is YAML 1.2 +--- +type: "job" +name: "rawfs" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/rawfs/rawfs.conf b/calamares/src/modules/rawfs/rawfs.conf new file mode 100644 index 0000000..bbc3690 --- /dev/null +++ b/calamares/src/modules/rawfs/rawfs.conf @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the rawfs module: raw filesystem copy to a block device + +--- + +# To apply a custom partition layout, it has to be defined as a list of targets. +# +# For each target, the following attributes must be defined: +# * mountPoint: The mount point of the destination device on the installed system +# The corresponding block device will automatically be identified and used as the +# destination for the operation +# * source: The source filesystem; it can be the mount point of a locally (on the +# live system) mounted filesystem, a path to a disk image, or a block device +# * resize (optional): Expand the destination filesystem to fill the whole +# partition at the end of the operation; this works only with ext filesystems +# for now + +targets: + - mountPoint: / + source: / + - mountPoint: /home + source: /images/home.img + resize: true + - mountPoint: /data + source: /dev/mmcblk0p3 + +# To support testing, set the *bogus* key to true. No actual work is done, but the +# module's logic is exercised. + +# bogus: false diff --git a/calamares/src/modules/rawfs/tests/1.global b/calamares/src/modules/rawfs/tests/1.global new file mode 100644 index 0000000..089557d --- /dev/null +++ b/calamares/src/modules/rawfs/tests/1.global @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir3/ +partitions: + - mountPoint: / + device: /dev/sda1 + fs: ext4 + - mountPoint: /home + device: /dev/sda2 + fs: ext4 diff --git a/calamares/src/modules/rawfs/tests/1.job b/calamares/src/modules/rawfs/tests/1.job new file mode 100644 index 0000000..c87a4a7 --- /dev/null +++ b/calamares/src/modules/rawfs/tests/1.job @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Testing configuration for rawfs +# +--- + +targets: + - mountPoint: / + source: / + - mountPoint: /home + source: /images/home.img + resize: true + - mountPoint: /data + source: /dev/mmcblk0p3 + +bogus: true diff --git a/calamares/src/modules/rawfs/tests/CMakeTests.txt b/calamares/src/modules/rawfs/tests/CMakeTests.txt new file mode 100644 index 0000000..c31bba9 --- /dev/null +++ b/calamares/src/modules/rawfs/tests/CMakeTests.txt @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# +# Special cases for rawfs tests +# +# - On FreeBSD, /proc/mounts doesn't exist (/proc is only about processes, +# and is rarely used). The test would fail, except it catches that +# kind of error and ends up doing nothing. + +if(CMAKE_SYSTEM_NAME MATCHES "FreeBSD") + # set_tests_properties(load-rawfs-1 PROPERTIES WILL_FAIL TRUE) + message(STATUS "rawfs tests are useless on FreeBSD") +endif() diff --git a/calamares/src/modules/removeuser/CMakeLists.txt b/calamares/src/modules/removeuser/CMakeLists.txt new file mode 100644 index 0000000..eaaf201 --- /dev/null +++ b/calamares/src/modules/removeuser/CMakeLists.txt @@ -0,0 +1,12 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(removeuser + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + RemoveUserJob.cpp + SHARED_LIB +) diff --git a/calamares/src/modules/removeuser/RemoveUserJob.cpp b/calamares/src/modules/removeuser/RemoveUserJob.cpp new file mode 100644 index 0000000..2eba510 --- /dev/null +++ b/calamares/src/modules/removeuser/RemoveUserJob.cpp @@ -0,0 +1,62 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Alf Gaida + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "RemoveUserJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include + +RemoveUserJob::RemoveUserJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +RemoveUserJob::~RemoveUserJob() {} + +QString +RemoveUserJob::prettyName() const +{ + return tr( "Removing live user from the target system…", "@status" ); +} + +Calamares::JobResult +RemoveUserJob::exec() +{ + if ( m_username.isEmpty() ) + { + cWarning() << "Ignoring an empty username."; + return Calamares::JobResult::ok(); + } + + auto* s = Calamares::System::instance(); + auto r = s->targetEnvCommand( { QStringLiteral( "userdel" ), + QStringLiteral( "-f" ), // force + QStringLiteral( "-r" ), // remove home-dir and mail + m_username } ); + if ( r.getExitCode() != 0 ) + { + cWarning() << "Cannot remove user" << m_username << "userdel terminated with exit code" << r.getExitCode(); + } + return Calamares::JobResult::ok(); +} + +void +RemoveUserJob::setConfigurationMap( const QVariantMap& map ) +{ + m_username = Calamares::getString( map, "username" ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( RemoveUserJobFactory, registerPlugin< RemoveUserJob >(); ) diff --git a/calamares/src/modules/removeuser/RemoveUserJob.h b/calamares/src/modules/removeuser/RemoveUserJob.h new file mode 100644 index 0000000..c8a4df1 --- /dev/null +++ b/calamares/src/modules/removeuser/RemoveUserJob.h @@ -0,0 +1,40 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef REMOVEUSERJOB_H +#define REMOVEUSERJOB_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include + +class PLUGINDLLEXPORT RemoveUserJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit RemoveUserJob( QObject* parent = nullptr ); + ~RemoveUserJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_username; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( RemoveUserJobFactory ) + +#endif // REMOVEUSERJOB_H diff --git a/calamares/src/modules/removeuser/removeuser.conf b/calamares/src/modules/removeuser/removeuser.conf new file mode 100644 index 0000000..cc086e7 --- /dev/null +++ b/calamares/src/modules/removeuser/removeuser.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Removes a single user (with userdel) from the system. +# This is typically used in OEM setups or if the live user +# spills into the target system. +# +# The module never fails; if userdel fails, this is logged +# but the module still reports success and installation / setup +# continues as normal. +--- +# Username in the target system to be removed. +username: live diff --git a/calamares/src/modules/removeuser/removeuser.schema.yaml b/calamares/src/modules/removeuser/removeuser.schema.yaml new file mode 100644 index 0000000..c282717 --- /dev/null +++ b/calamares/src/modules/removeuser/removeuser.schema.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/removeuser +additionalProperties: false +type: object +properties: + username: { type: string } +required: [ username ] diff --git a/calamares/src/modules/services-openrc/main.py b/calamares/src/modules/services-openrc/main.py new file mode 100644 index 0000000..cb1ae80 --- /dev/null +++ b/calamares/src/modules/services-openrc/main.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2016 Artoo +# SPDX-FileCopyrightText: 2017 Philip Müller +# SPDX-FileCopyrightText: 2018 Artoo +# SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import libcalamares + +from libcalamares.utils import target_env_call, warning +from os.path import exists, join + + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Configure OpenRC services") + + +class OpenrcController: + """ + This is the openrc service controller. + All of its state comes from global storage and the job + configuration at initialization time. + """ + + def __init__(self): + self.root = libcalamares.globalstorage.value('rootMountPoint') + + # Translate the entries in the config to the actions passed to rc-config + self.services = dict() + self.services["add"] = libcalamares.job.configuration.get('services', []) + self.services["del"] = libcalamares.job.configuration.get('disable', []) + + self.initdDir = libcalamares.job.configuration['initdDir'] + self.runlevelsDir = libcalamares.job.configuration['runlevelsDir'] + + + def make_failure_description(self, state, name, runlevel): + """ + Returns a generic "could not " failure message, specialized + for the action @p state and the specific service @p name in @p runlevel. + """ + if state == "add": + description = _("Cannot add service {name!s} to run-level {level!s}.") + elif state == "del": + description = _("Cannot remove service {name!s} from run-level {level!s}.") + else: + description = _("Unknown service-action {arg!s} for service {name!s} in run-level {level!s}.") + + return description.format(arg=state, name=name, level=runlevel) + + + def update(self, state): + """ + Call rc-update for each service listed + in services for the given @p state. rc-update + is called with @p state as the command as well. + """ + + for svc in self.services.get(state, []): + if isinstance(svc, str): + name = svc + runlevel = "default" + mandatory = False + else: + name = svc["name"] + runlevel = svc.get("runlevel", "default") + mandatory = svc.get("mandatory", False) + + service_path = self.root + self.initdDir + "/" + name + runlevel_path = self.root + self.runlevelsDir + "/" + runlevel + + if exists(service_path): + if exists(runlevel_path): + ec = target_env_call(["rc-update", state, name, runlevel]) + if ec != 0: + warning("Cannot {} service {} to {}".format(state, name, runlevel)) + warning("rc-update returned error code {!s}".format(ec)) + if mandatory: + title = _("Cannot modify service") + diagnostic = _("rc-update {arg!s} call in chroot returned error code {num!s}.").format(arg=state, num=ec) + return (title, + self.make_failure_description(state, name, runlevel) + " " + diagnostic + ) + else: + warning("Target runlevel {} does not exist for {}.".format(runlevel, name)) + if mandatory: + title = _("Target runlevel does not exist") + diagnostic = _("The path for runlevel {level!s} is {path!s}, which does not exist.").format(level=runlevel, path=runlevel_path) + + return (title, + self.make_failure_description(state, name, runlevel) + " " + diagnostic + ) + else: + warning("Target service {} does not exist in {}.".format(name, self.initdDir)) + if mandatory: + title = _("Target service does not exist") + diagnostic = _("The path for service {name!s} is {path!s}, which does not exist.").format(name=name, path=service_path) + return (title, + self.make_failure_description(state, name, runlevel) + " " + diagnostic + ) + + + def run(self): + """Run the controller + """ + + for state in ("add", "del"): + r = self.update(state) + if r is not None: + return r + +def run(): + """ + Setup services + """ + + return OpenrcController().run() diff --git a/calamares/src/modules/services-openrc/module.desc b/calamares/src/modules/services-openrc/module.desc new file mode 100644 index 0000000..c60872b --- /dev/null +++ b/calamares/src/modules/services-openrc/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "services-openrc" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/services-openrc/services-openrc.conf b/calamares/src/modules/services-openrc/services-openrc.conf new file mode 100644 index 0000000..6042b53 --- /dev/null +++ b/calamares/src/modules/services-openrc/services-openrc.conf @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# openrc services module to modify service runlevels via rc-update in the chroot +# +# Services can be added (to any runlevel, or multiple runlevels) or deleted. +# Handle del with care and only use it if absolutely necessary. +# +# if a service is listed in the conf but is not present/detected on the target system, +# or a runlevel does not exist, it will be ignored and skipped; a warning is logged. +# +--- +# initdDir: holds the openrc service directory location +initdDir: /etc/init.d + +# runlevelsDir: holds the runlevels directory location +runlevelsDir: /etc/runlevels + +# services: a list of entries to **enable** +# disable: a list of entries to **disable** +# +# Each entry has three fields: +# - name: the service name +# - (optional) runlevel: can hold any runlevel present on the target +# system; if no runlevel is provided, "default" is assumed. +# - (optional) mandatory: if set to true, a failure to modify +# the service will result in installation failure, rather than just +# a warning. The default is false. +# +# an entry may also be a single string, which is interpreted +# as the name field (runlevel "default" is assumed then, and not-mandatory). +# +# # Example services and disable settings: +# # - add foo1 to default, but it must succeed +# # - add foo2 to nonetwork +# # - remove foo3 from default +# # - remove foo4 from default +# services: +# - name: foo1 +# mandatory: true +# - name: foo2 +# runlevel: nonetwork +# disable: +# - name: foo3 +# runlevel: default +# - foo4 +services: [] +disable: [] + diff --git a/calamares/src/modules/services-systemd/main.py b/calamares/src/modules/services-systemd/main.py new file mode 100644 index 0000000..19c5974 --- /dev/null +++ b/calamares/src/modules/services-systemd/main.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Philip Müller +# SPDX-FileCopyrightText: 2014 Teo Mrnjavac +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2018-2019 Adriaan de Groot +# SPDX-FileCopyrightText: 2022 shivanandvp +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. + +import libcalamares + + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Configure systemd units") + + +def systemctl(units): + """ + For each entry in @p units, run "systemctl ", + where each unit is a mapping of unit name, action, and a flag. + + Returns a failure message, or None if this was successful. + Units that are not mandatory have their failures suppressed + silently. + """ + + for unit in units: + if isinstance(unit, str): + name = unit + action = "enable" + mandatory = False + else: + if "name" not in unit: + libcalamares.utils.error("The key 'name' is missing from the mapping {_unit!s}. Continuing to the next unit.".format(_unit=str(unit))) + continue + name = unit["name"] + action = unit.get("action", "enable") + mandatory = unit.get("mandatory", False) + + exit_code = libcalamares.utils.target_env_call( + ['systemctl', action, name] + ) + + if exit_code != 0: + libcalamares.utils.warning( + "Cannot {} systemd unit {}".format(action, name) + ) + libcalamares.utils.warning( + "systemctl {} call in chroot returned error code {}".format(action, exit_code) + ) + if mandatory: + title = _("Cannot modify unit") + diagnostic = _("systemctl {_action!s} call in chroot returned error code {_exit_code!s}.").format(_action=action, _exit_code=exit_code) + description = _("Cannot {_action!s} systemd unit {_name!s}.").format(_action=action, _name=name) + return ( + title, + description + " " + diagnostic + ) + return None + + +def run(): + """ + Setup systemd units + """ + cfg = libcalamares.job.configuration + + return_value = systemctl( + cfg.get("units", []) + ) + if return_value is not None: + return return_value + + return None diff --git a/calamares/src/modules/services-systemd/module.desc b/calamares/src/modules/services-systemd/module.desc new file mode 100644 index 0000000..e016c6d --- /dev/null +++ b/calamares/src/modules/services-systemd/module.desc @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "services-systemd" +interface: "python" +script: "main.py" diff --git a/calamares/src/modules/services-systemd/services-systemd.conf b/calamares/src/modules/services-systemd/services-systemd.conf new file mode 100644 index 0000000..330a94c --- /dev/null +++ b/calamares/src/modules/services-systemd/services-systemd.conf @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Systemd units manipulation. +# +# This module can perform actions using systemd units, +# (for example, enabling, disabling, or masking services, sockets, paths, etc.) +--- + +# There is one key for this module: *units*. Its value is a list of entries. +# Each entry has three keys: +# - *name* is the (string) name of the systemd unit that is being changed. +# Use quotes. You can use any valid systemd unit here (for example, +# "NetworkManager.service", "cups.socket", "lightdm", "gdm", etc.) +# - *action* is the (string) action that you want to perform over the unit +# (for example, "enable", "disable", "mask", "unmask", etc.). Please +# ensure that the action can actually run under chroot (otherwise it is +# pointless) +# - *mandatory* is a boolean option, which states whether the change +# must be done successfully. If systemd reports an error while changing +# a mandatory entry, the installation will fail. When mandatory is false, +# errors for that systemd unit are ignored. If mandatory +# is not specified, the default is false. +# +# The order of operations is the same as the order in which entries +# appear in the list + +# # This example enables NetworkManager.service (and fails if it can't), +# # disables cups.socket (and ignores failure). Then it enables the +# # graphical target (e.g. so that SDDM runs for login), and +# # finally masks pacman-init (an ArchLinux-only service). +# # +# units: +# - name: "NetworkManager.service" +# action: "enable" +# mandatory: true +# +# - name: "cups.socket" +# action: "disable" +# # The property "mandatory" is taken to be false by default here +# # because it is not specified +# +# - name: "graphical.target" +# action: "enable" +# # The property "mandatory" is taken to be false by default here +# # because it is not specified +# +# - name: "pacman-init.service" +# action: "mask" +# # The property "mandatory" is taken to be false by default here +# # because it is not specified + +# By default, no changes are made. +units: [] diff --git a/calamares/src/modules/services-systemd/services-systemd.schema.yaml b/calamares/src/modules/services-systemd/services-systemd.schema.yaml new file mode 100644 index 0000000..7e1fe05 --- /dev/null +++ b/calamares/src/modules/services-systemd/services-systemd.schema.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/services-systemd +definitions: + unit: + $id: 'definitions/unit' + type: object + description: a map containing a unit name, an action, and whether it is mandatory + additionalProperties: false + properties: + name: { type: string } + action: { type: string, default: "enable" } + mandatory: { type: boolean, default: false } + required: [ name ] + +additionalProperties: false +type: object +properties: + units: { type: array, items: { $ref: 'definitions/unit' } } diff --git a/calamares/src/modules/shellprocess/CMakeLists.txt b/calamares/src/modules/shellprocess/CMakeLists.txt new file mode 100644 index 0000000..8ac444f --- /dev/null +++ b/calamares/src/modules/shellprocess/CMakeLists.txt @@ -0,0 +1,14 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(shellprocess + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + ShellProcessJob.cpp + SHARED_LIB +) + +calamares_add_test(shellprocesstest SOURCES Tests.cpp LIBRARIES yamlcpp::yamlcpp) diff --git a/calamares/src/modules/shellprocess/ShellProcessJob.cpp b/calamares/src/modules/shellprocess/ShellProcessJob.cpp new file mode 100644 index 0000000..d6fa9ac --- /dev/null +++ b/calamares/src/modules/shellprocess/ShellProcessJob.cpp @@ -0,0 +1,91 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ShellProcessJob.h" + +#include "CalamaresVersion.h" +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "utils/CommandList.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include +#include + +ShellProcessJob::ShellProcessJob( QObject* parent ) + : Calamares::CppJob( parent ) + , m_commands( nullptr ) +{ +} + +ShellProcessJob::~ShellProcessJob() {} + +QString +ShellProcessJob::prettyName() const +{ + if ( m_name ) + { + return m_name->get(); + } + return tr( "Running shell processes…", "@status" ); +} + +Calamares::JobResult +ShellProcessJob::exec() +{ + + if ( !m_commands || m_commands->isEmpty() ) + { + cWarning() << "No commands to execute" << moduleInstanceKey(); + return Calamares::JobResult::ok(); + } + + return m_commands->run(); +} + +void +ShellProcessJob::setConfigurationMap( const QVariantMap& configurationMap ) +{ + bool dontChroot = Calamares::getBool( configurationMap, "dontChroot", false ); + qint64 timeout = Calamares::getInteger( configurationMap, "timeout", 30 ); + if ( timeout < 1 ) + { + timeout = 30; + } + bool verbose = Calamares::getBool( configurationMap, "verbose", false ); + + if ( configurationMap.contains( "script" ) ) + { + m_commands = std::make_unique< Calamares::CommandList >( + configurationMap.value( "script" ), !dontChroot, std::chrono::seconds( timeout ) ); + if ( m_commands->isEmpty() ) + { + cDebug() << "ShellProcessJob: \"script\" contains no commands for" << moduleInstanceKey(); + } + m_commands->updateVerbose( verbose ); + } + else + { + cWarning() << "No script given for ShellProcessJob" << moduleInstanceKey(); + } + + bool labels_ok = false; + auto labels = Calamares::getSubMap( configurationMap, "i18n", labels_ok ); + if ( labels_ok ) + { + if ( labels.contains( "name" ) ) + { + m_name = std::make_unique< Calamares::Locale::TranslatedString >( labels, "name" ); + } + } +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( ShellProcessJobFactory, registerPlugin< ShellProcessJob >(); ) diff --git a/calamares/src/modules/shellprocess/ShellProcessJob.h b/calamares/src/modules/shellprocess/ShellProcessJob.h new file mode 100644 index 0000000..79886e3 --- /dev/null +++ b/calamares/src/modules/shellprocess/ShellProcessJob.h @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SHELLPROCESSJOB_H +#define SHELLPROCESSJOB_H + +#include "CppJob.h" +#include "DllMacro.h" + +#include "locale/TranslatableConfiguration.h" +#include "utils/CommandList.h" +#include "utils/PluginFactory.h" + +#include +#include + +#include + +class PLUGINDLLEXPORT ShellProcessJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit ShellProcessJob( QObject* parent = nullptr ); + ~ShellProcessJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + std::unique_ptr< Calamares::CommandList > m_commands; + std::unique_ptr< Calamares::Locale::TranslatedString > m_name; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( ShellProcessJobFactory ) + +#endif // SHELLPROCESSJOB_H diff --git a/calamares/src/modules/shellprocess/Tests.cpp b/calamares/src/modules/shellprocess/Tests.cpp new file mode 100644 index 0000000..5895768 --- /dev/null +++ b/calamares/src/modules/shellprocess/Tests.cpp @@ -0,0 +1,206 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Tests.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" + +#include "utils/CommandList.h" +#include "utils/Logger.h" +#include "utils/Yaml.h" + +#include + +#include +#include + +QTEST_GUILESS_MAIN( ShellProcessTests ) + +using CommandList = Calamares::CommandList; +using std::operator""s; + +ShellProcessTests::ShellProcessTests() {} + +ShellProcessTests::~ShellProcessTests() {} + +void +ShellProcessTests::initTestCase() +{ +} + +void +ShellProcessTests::testProcessListSampleConfig() +{ + ::YAML::Node doc; + + QString filename = QStringLiteral( "shellprocess.conf" ); + QFile fi( QString( "%1/%2" ).arg( BUILD_AS_TEST, filename ) ); + + QVERIFY( fi.exists() ); + doc = ::YAML::LoadFile( fi.fileName().toStdString() ); + + CommandList cl( Calamares::YAML::mapToVariant( doc ).value( "script" ) ); + QVERIFY( !cl.isEmpty() ); + QCOMPARE( cl.count(), 4 ); + + QCOMPARE( cl.at( 0 ).timeout(), Calamares::CommandLine::TimeoutNotSet() ); + QCOMPARE( cl.at( 2 ).timeout(), 3600s ); // slowloris +} + +void +ShellProcessTests::testProcessListFromList() +{ + ::YAML::Node doc = ::YAML::Load( R"(--- +script: + - "ls /tmp" + - "ls /nonexistent" + - "/bin/false" +)" ); + CommandList cl( Calamares::YAML::mapToVariant( doc ).value( "script" ) ); + QVERIFY( !cl.isEmpty() ); + QCOMPARE( cl.count(), 3 ); + + // Contains 1 bad element + doc = ::YAML::Load( R"(--- +script: + - "ls /tmp" + - false + - "ls /nonexistent" +)" ); + CommandList cl1( Calamares::YAML::mapToVariant( doc ).value( "script" ) ); + QVERIFY( !cl1.isEmpty() ); + QCOMPARE( cl1.count(), 2 ); // One element ignored +} + +void +ShellProcessTests::testProcessListFromString() +{ + YAML::Node doc = YAML::Load( R"(--- +script: "ls /tmp" +)" ); + CommandList cl( Calamares::YAML::mapToVariant( doc ).value( "script" ) ); + + QVERIFY( !cl.isEmpty() ); + QCOMPARE( cl.count(), 1 ); + QCOMPARE( cl.at( 0 ).timeout(), 10s ); + QCOMPARE( cl.at( 0 ).command(), QStringLiteral( "ls /tmp" ) ); + + // Not a string + doc = YAML::Load( R"(--- +script: false +)" ); + CommandList cl1( Calamares::YAML::mapToVariant( doc ).value( "script" ) ); + QVERIFY( cl1.isEmpty() ); + QCOMPARE( cl1.count(), 0 ); +} + +void +ShellProcessTests::testProcessFromObject() +{ + YAML::Node doc = YAML::Load( R"(--- +script: + command: "ls /tmp" + timeout: 20 +)" ); + CommandList cl( Calamares::YAML::mapToVariant( doc ).value( "script" ) ); + + QVERIFY( !cl.isEmpty() ); + QCOMPARE( cl.count(), 1 ); + QCOMPARE( cl.at( 0 ).timeout(), 20s ); + QCOMPARE( cl.at( 0 ).command(), QStringLiteral( "ls /tmp" ) ); +} + +void +ShellProcessTests::testProcessListFromObject() +{ + YAML::Node doc = YAML::Load( R"(--- +script: + - command: "ls /tmp" + timeout: 12 + - "-/bin/false" +)" ); + CommandList cl( Calamares::YAML::mapToVariant( doc ).value( "script" ) ); + QVERIFY( !cl.isEmpty() ); + QCOMPARE( cl.count(), 2 ); + QCOMPARE( cl.at( 0 ).timeout(), 12s ); + QCOMPARE( cl.at( 0 ).command(), QStringLiteral( "ls /tmp" ) ); + QCOMPARE( cl.at( 1 ).timeout(), Calamares::CommandLine::TimeoutNotSet() ); // not set +} + +void +ShellProcessTests::testRootSubstitution() +{ + YAML::Node doc = YAML::Load( R"(--- +script: + - "ls /tmp" +)" ); + QVariant plainScript = Calamares::YAML::mapToVariant( doc ).value( "script" ); + QVariant rootScript = Calamares::YAML::mapToVariant( YAML::Load( R"(--- +script: + - "ls ${ROOT}" +)" ) ) + .value( "script" ); + QVariant userScript = Calamares::YAML::mapToVariant( YAML::Load( R"(--- +script: + - mktemp -d ${ROOT}/calatestXXXXXXXX + - "chown ${USER} ${ROOT}/calatest*" + - rm -rf ${ROOT}/calatest* +)" ) ) + .value( "script" ); + + if ( !Calamares::JobQueue::instance() ) + { + (void)new Calamares::JobQueue( nullptr ); + } + if ( !Calamares::Settings::instance() ) + { + (void)Calamares::Settings::init( QString() ); + } + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + QVERIFY( gs != nullptr ); + + qDebug() << "Expect WARNING, ERROR, WARNING"; + + // Doesn't use ${ROOT}, so no failures + QVERIFY( bool( CommandList( plainScript, false, 10s ).run() ) ); + + // Doesn't use ${ROOT}, but does chroot, so fails + QVERIFY( !bool( CommandList( plainScript, true, 10s ).run() ) ); + + // Does use ${ROOT}, which is not set, so fails + QVERIFY( !bool( CommandList( rootScript, false, 10s ).run() ) ); + // .. fails for two reasons + QVERIFY( !bool( CommandList( rootScript, true, 10s ).run() ) ); + + gs->insert( "rootMountPoint", "/tmp" ); + // Now that the root is set, two variants work .. still can't + // chroot, unless the rootMountPoint contains a full system, + // *and* we're allowed to chroot (ie. running tests as root). + qDebug() << "Expect no output."; + QVERIFY( bool( CommandList( plainScript, false, 10s ).run() ) ); + QVERIFY( bool( CommandList( rootScript, false, 10s ).run() ) ); + + qDebug() << "Expect ERROR"; + // But no user set yet + QVERIFY( !bool( CommandList( userScript, false, 10s ).run() ) ); + + // Show that shell expansion is now quoted. + gs->insert( "username", "`id -u`" ); + { + Calamares::CommandLine c { QStringLiteral( "chown ${USER}" ), std::chrono::seconds( 0 ) }; + QCOMPARE( c.expand().command(), QStringLiteral( "chown '`id -u`'" ) ); + } + // Now play dangerous games with shell expansion -- except the internal command is now + // quoted, so this fails because it's **highly** unlikely that the literal string + // "`id -u`" is a valid username. + QVERIFY( !bool( CommandList( userScript, false, 10s ).run() ) ); +} diff --git a/calamares/src/modules/shellprocess/Tests.h b/calamares/src/modules/shellprocess/Tests.h new file mode 100644 index 0000000..c44fa85 --- /dev/null +++ b/calamares/src/modules/shellprocess/Tests.h @@ -0,0 +1,38 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TESTS_H +#define TESTS_H + +#include + +class ShellProcessTests : public QObject +{ + Q_OBJECT +public: + ShellProcessTests(); + ~ShellProcessTests() override; + +private Q_SLOTS: + void initTestCase(); + // Check the sample config file is processed correctly + void testProcessListSampleConfig(); + // Create from a YAML list + void testProcessListFromList(); + // Create from a simple YAML string + void testProcessListFromString(); + // Create from a single complex YAML + void testProcessFromObject(); + // Create from a complex YAML list + void testProcessListFromObject(); + // Check variable substitution + void testRootSubstitution(); +}; + +#endif diff --git a/calamares/src/modules/shellprocess/shellprocess.conf b/calamares/src/modules/shellprocess/shellprocess.conf new file mode 100644 index 0000000..709d2a8 --- /dev/null +++ b/calamares/src/modules/shellprocess/shellprocess.conf @@ -0,0 +1,146 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the shell process job. +# +# Executes a list of commands found under the key *script*. +# If the top-level key *dontChroot* is true, then the commands +# are executed in the context of the live system, otherwise +# in the context of the target system. In all of the commands, +# the following variable expansions will take place: +# - `ROOT` is replaced by the root mount point of the **target** +# system from the point of view of the command (when run in the target +# system, e.g. when *dontChroot* is false, that will be `/`). +# - `USER` is replaced by the username, set on the user page. +# - `LANG` is replaced by the language chosen for the user-interface +# of Calamares, set on the welcome page. This may not reflect the +# chosen system language from the locale page. +# +# As a special case, variables of the form `gs[key]` where `key` is +# a dotted-keys string and `gs` is literally the letters `g` and `s`, +# use **any** value from Global Storage. For example, +# +# gs[branding.bootloader] +# +# This variable refers to the GS value stored in `bootloader` in the +# `branding` map. Examine the Debug window for information about the +# keys stored in GS. Only strings and integers are exposed this way, +# lists and other data types do not set any variable this way. +# +# Variables are written as `${var}`, e.g. `${ROOT}`. +# Write `$$` to get a shell-escaped `\$` in the shell command. +# It is not possible to get an un-escaped `$` in the shell command +# (either the command will fail because of undefined variables, or +# you get a shell-escaped `\$`). +# +# The (global) timeout for the command list can be set with +# the *timeout* key. The value is a time in seconds, default +# is 30 seconds if not set. The timeout **must** be tuned, either +# globally or per-command (see below in the description of *script*), +# to the load or expected running-time of the command. +# +# - Setting a timeout of 30 for a `touch` command is probably exessive +# - Setting a timeout of 1 for a `touch` command might be low, +# on a slow disk where touch needs to be loaded from CDROM +# - Setting a timeout of 30 for a 1GB download is definitely low +# - Setting a timeout of 3600 for a 1GB download is going to leave +# the user in uncertainty for a loooong time. +# +# The (global) verbosity of a command can be set to `true` or `false`. +# When set to `true`, command output is logged one line at a time. +# Otherwise the output is logged when the command completes. +# Line-at-a-time logging is appropriate for commands that take +# a long time to complete and produce their own (progress) output. +# +# If a command starts with "-" (a single minus sign), then the +# return value of the command following the - is ignored; otherwise, +# a failing command will abort the installation. This is much like +# make's use of - in a command. +# +# The value of *script* may be: +# - a single string; this is one command that is executed. +# - a single object (see below). +# - a list of items; these are executed one at a time, by +# separate shells (/bin/sh -c is invoked for each command). +# Each list item may be: +# - a single string; this is one command that is executed. +# - a single object, specifying a key *command* and (optionally) +# a key *timeout* to set the timeout for this specific +# command differently from the global setting. An optional +# key *environment* is a list of strings to put into the +# environment of the command. An optional key *verbose* +# overrides the global *verbose* setting in this file. +# +# Using a single object is not generally useful because the same effect +# can be obtained with a single string and a global timeout, except +# when the command needs environment-settings. When there are +# multiple commands to execute, one of them might have +# a different timeout than the others. +# +# The environment strings should all be "KEY='some value'" strings, +# as if they can be typed into the shell. Quoting the environment +# strings with "" in YAML is recommended. Adding the '' quotes ensures +# that the value will not be interpreted by the shell. Writing +# environment strings is the same as placing `export KEY='some value' ;` +# in front of the *command*. +# +# Calamares variable expansion is **also** done on the environment strings. +# Write `$$` to get a literal `$` in the shell command. +# +# To change the description of the job, set the *name* entries in *i18n*. +--- +# Set to true to run in host, rather than target system +dontChroot: false + +# Tune this for the commands you're actually running, or +# use the list-of-items form of commands to tune the timeout +# for each command individually. +timeout: 10 + +# This will copy the output from the command into the Calamares +# log file. No processing is done beyond log-each-line-separately, +# so this can introduce weirdness in the log if the script +# outputs e.g. escape codes. +# +# The default is `false`. This can also be set for each +# command individually. +verbose: false + +# Script may be a single string (because false returns an error exit +# code, this will trigger a failure in the installation): +# +# script: "/usr/bin/false" + +# Script may be a list of strings (because false returns an error exit +# code, **but** the command starts with a "-", the error exit is +# ignored and installation continues): +# +# script: +# - "-/usr/bin/false" +# - "/bin/ls" +# - "/usr/bin/true" + +# Script may be a list of items +# - if the touch command fails, it is ignored +# - there is nothing special about the invocation of true +# - the slowloris command has a different timeout from the other commands +# - the echo command logs its output line-by-line +script: + - "-touch ${ROOT}/tmp/thingy" + - "/usr/bin/true" + - command: "/usr/local/bin/slowloris" + timeout: 3600 + - command: "echo -e '\e[33;2mred\e[33;0m' ; echo second line" + verbose: true + +# You can change the description of the job (as it is displayed in the +# progress bar during installation) by defining an *i18n* key, which +# has a *name* field and optionally, translations as *name[lang]*. +# +# Without a translation here, the default name from the source code +# is used, "Shell Processes Job". +# +# i18n: +# name: "Shell process" +# name[nl]: "Schelpenpad" +# name[en_GB]: "Just a moment" diff --git a/calamares/src/modules/shellprocess/shellprocess.schema.yaml b/calamares/src/modules/shellprocess/shellprocess.schema.yaml new file mode 100644 index 0000000..c9f6c34 --- /dev/null +++ b/calamares/src/modules/shellprocess/shellprocess.schema.yaml @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: benne-dee +# SPDX-License-Identifier: GPL-3.0-or-later + +$schema: http://json-schema.org/draft-07/schema# +$id: https://calamares.io/schemas/shellprocess +definitions: + command: + $id: '#definitions/command' + type: string + description: This is one command that is executed. If a command starts with '-' + (a single minus sign), then the return value of the command following the - + is ignored; otherwise, a failing command will abort the installation. + commandObj: + $id: '#definitions/commandObj' + type: object + properties: + command: + $ref: '#definitions/command' + timeout: + type: number + description: the (optional) timeout for this specific command (differently + from the global setting). + verbose: + type: boolean + description: when true, log output from the command to the Calamares log. + required: + - command +type: object +description: Configuration for the shell process job. +properties: + dontChroot: + type: boolean + description: If the top-level key *dontChroot* is true, then the commands are + executed in the context of the live system, otherwise in the context of the + target system. + timeout: + type: number + description: The (global) timeout for the command list in seconds. If unset, defaults + to 30 seconds. + verbose: + type: boolean + description: when true, log output from the command to the Calamares log. + script: + anyOf: + - $ref: '#definitions/command' + - $ref: '#definitions/commandObj' + - type: array + description: these commands are executed one at a time, by separate shells (/bin/sh + -c is invoked for each command). + items: + anyOf: + - $ref: '#definitions/command' + - $ref: '#definitions/commandObj' + i18n: + type: object + description: To change description of the job (as it is displayed in the progress + bar during installation) use *name* field and optionally, translations as *name[lang]*. + Without a translation, the default name from the source code is used, "Shell Processes Job". + properties: + name: + type: string + required: + - name +required: + - script diff --git a/calamares/src/modules/summary/CMakeLists.txt b/calamares/src/modules/summary/CMakeLists.txt new file mode 100644 index 0000000..671cf56 --- /dev/null +++ b/calamares/src/modules/summary/CMakeLists.txt @@ -0,0 +1,18 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +include_directories(${PROJECT_BINARY_DIR}/src/libcalamaresui) +calamares_add_plugin(summary + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + SummaryModel.cpp + SummaryPage.cpp + SummaryViewStep.cpp + UI + SHARED_LIB + NO_CONFIG +) diff --git a/calamares/src/modules/summary/Config.cpp b/calamares/src/modules/summary/Config.cpp new file mode 100644 index 0000000..387b030 --- /dev/null +++ b/calamares/src/modules/summary/Config.cpp @@ -0,0 +1,92 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020, Camilo Higuita + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "SummaryModel.h" + +#include "Branding.h" +#include "Settings.h" +#include "ViewManager.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "viewpages/ExecutionViewStep.h" + +Config::Config( QObject* parent ) + : QObject( parent ) + , m_summary( new SummaryModel( this ) ) + +{ + CALAMARES_RETRANSLATE_SLOT( &Config::retranslate ); + retranslate(); +} + +void +Config::retranslate() +{ + m_title = tr( "Summary", "@label" ); + + if ( Calamares::Settings::instance()->isSetupMode() ) + { + m_message = tr( "This is an overview of what will happen once you start " + "the setup procedure." ); + } + else + { + m_message = tr( "This is an overview of what will happen once you start " + "the install procedure." ); + } + Q_EMIT titleChanged( m_title ); + Q_EMIT messageChanged( m_message ); +} + +Calamares::ViewStepList +Config::stepsForSummary( const Calamares::ViewStep* upToHere ) +{ + Calamares::ViewStepList steps; + for ( Calamares::ViewStep* step : Calamares::ViewManager::instance()->viewSteps() ) + { + // *Assume* that if there's an exec step in the sequence, + // we don't need a summary for steps before it. This works in + // practice if there's a summary step before each exec -- + // and in practice, there's only one of each. + if ( qobject_cast< Calamares::ExecutionViewStep* >( step ) ) + { + steps.clear(); + continue; + } + + // Having reached the parent view-step of the Config object, + // we know we're providing a summary of steps up until this + // view step, so we now have steps since the previous exec, up + // to this summary. + if ( upToHere == step ) + { + break; + } + + steps.append( step ); + } + return steps; +} + + +void +Config::collectSummaries( const Calamares::ViewStep* upToHere, Widgets withWidgets ) +{ + m_summary->setSummaryList( stepsForSummary( upToHere ), withWidgets == Widgets::Enabled ); +} + +void +Config::clearSummaries() +{ + m_summary->setSummaryList( {}, false ); +} diff --git a/calamares/src/modules/summary/Config.h b/calamares/src/modules/summary/Config.h new file mode 100644 index 0000000..25381b1 --- /dev/null +++ b/calamares/src/modules/summary/Config.h @@ -0,0 +1,63 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020, Adriaan de Groot + * SPDX-FileCopyrightText: 2020, Camilo Higuita + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SUMMARY_CONFIG_H +#define SUMMARY_CONFIG_H + +#include "SummaryModel.h" + +#include "viewpages/ViewStep.h" + +class Config : public QObject +{ + Q_OBJECT + + ///@brief Name of this summary (generally, "Summary") + Q_PROPERTY( QString title READ title NOTIFY titleChanged ) + ///@brief Description of what the summary means + Q_PROPERTY( QString message READ message NOTIFY messageChanged ) + + Q_PROPERTY( QAbstractListModel* summaryModel READ summaryModel CONSTANT FINAL ) + +public: + explicit Config( QObject* parent = nullptr ); + + ///@brief Include widgets in the model? + enum class Widgets + { + Disabled, + Enabled + }; + + static Calamares::ViewStepList stepsForSummary( const Calamares::ViewStep* upToHere ); + + ///@brief Called later, to load the model once all viewsteps are there + void collectSummaries( const Calamares::ViewStep* upToHere, Widgets withWidgets ); + ///@brief Clear the model of steps (to avoid dangling widgets) + void clearSummaries(); + + QAbstractListModel* summaryModel() const { return m_summary; } + + QString title() const { return m_title; } + QString message() const { return m_message; } + +private: + void retranslate(); + + SummaryModel* m_summary; + + QString m_title; + QString m_message; + +Q_SIGNALS: + void titleChanged( QString title ); + void messageChanged( QString message ); +}; +#endif diff --git a/calamares/src/modules/summary/SummaryModel.cpp b/calamares/src/modules/summary/SummaryModel.cpp new file mode 100644 index 0000000..be8b062 --- /dev/null +++ b/calamares/src/modules/summary/SummaryModel.cpp @@ -0,0 +1,74 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020, Camilo Higuita + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SummaryModel.h" + +#include + +SummaryModel::SummaryModel( QObject* parent ) + : QAbstractListModel( parent ) +{ +} + +QHash< int, QByteArray > +SummaryModel::roleNames() const +{ + // Not including WidgetRole here because that wouldn't make sense + // in a QML context which is where the roleNames are important. + return { { TitleRole, "title" }, { MessageRole, "message" } }; +} + +QVariant +SummaryModel::data( const QModelIndex& index, int role ) const +{ + if ( !index.isValid() ) + { + return QVariant(); + } + auto& item = m_summary.at( index.row() ); + switch ( role ) + { + case TitleRole: + return item.title; + case MessageRole: + return item.message; + case WidgetRole: + return item.widget ? QVariant::fromValue( item.widget ) : QVariant(); + default: + return QVariant(); + } +} + +int +SummaryModel::rowCount( const QModelIndex& ) const +{ + return m_summary.count(); +} + +void +SummaryModel::setSummaryList( const Calamares::ViewStepList& steps, bool withWidgets ) +{ + beginResetModel(); + m_summary.clear(); + + for ( Calamares::ViewStep* step : steps ) + { + QString text = step->prettyStatus(); + QWidget* widget = withWidgets ? step->createSummaryWidget() : nullptr; + + if ( text.isEmpty() && !widget ) + { + continue; + } + + m_summary << StepSummary { step->prettyName(), text, widget }; + } + endResetModel(); +} diff --git a/calamares/src/modules/summary/SummaryModel.h b/calamares/src/modules/summary/SummaryModel.h new file mode 100644 index 0000000..919b5d5 --- /dev/null +++ b/calamares/src/modules/summary/SummaryModel.h @@ -0,0 +1,67 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020, Adriaan de Groot + * SPDX-FileCopyrightText: 2020, Camilo Higuita + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SUMMARY_SUMMARYMODEL_H +#define SUMMARY_SUMMARYMODEL_H + +#include "viewpages/ViewStep.h" + +#include +#include + +class Config; + +/** @brief Data for one step + * + * A step generally has a text description, but **may** have a + * QWidget. There is no ownership of the QWidget, that is assumed + * to be handed off to some owning parent-widget. + */ +struct StepSummary +{ + QString title; + QString message; + QWidget* widget = nullptr; +}; + +class SummaryModel : public QAbstractListModel +{ + Q_OBJECT + friend class Config; + +public: + enum Roles : int + { + TitleRole = Qt::DisplayRole, // Name of the step + MessageRole = Qt::UserRole, // String saying what it will do + WidgetRole, // Pointer to widget + }; + + explicit SummaryModel( QObject* parent = nullptr ); + int rowCount( const QModelIndex& = QModelIndex() ) const override; + QVariant data( const QModelIndex& index, int role ) const override; + +protected: + QHash< int, QByteArray > roleNames() const override; + +private: + /** @brief Sets the model data from @p steps + * + * Replaces the list of summaries with summaries given by + * the jobs and ViewSteps objects in @p steps. If @p withWidgets + * is @c true, then also queries for widget summaries alongside + * the text summaries for each step. + */ + void setSummaryList( const Calamares::ViewStepList& steps, bool withWidgets = false ); + + QVector< StepSummary > m_summary; +}; + +#endif diff --git a/calamares/src/modules/summary/SummaryPage.cpp b/calamares/src/modules/summary/SummaryPage.cpp new file mode 100644 index 0000000..41881e4 --- /dev/null +++ b/calamares/src/modules/summary/SummaryPage.cpp @@ -0,0 +1,176 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 2019, Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SummaryPage.h" + +#include "SummaryViewStep.h" + +#include "Branding.h" +#include "Settings.h" +#include "ViewManager.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/QtCompat.h" +#include "utils/Retranslator.h" +#include "viewpages/ExecutionViewStep.h" + +#include +#include +#include + +SummaryPage::SummaryPage( Config* config, QWidget* parent ) + : QWidget() + , m_contentWidget( nullptr ) + , m_scrollArea( new QScrollArea( this ) ) +{ + Q_UNUSED( parent ) + + this->setObjectName( "summaryStep" ); + + QVBoxLayout* layout = new QVBoxLayout( this ); + layout->setContentsMargins( 0, 0, 0, 0 ); + + QLabel* headerLabel = new QLabel( this ); + headerLabel->setObjectName( "summaryTitle" ); + headerLabel->setText( config->message() ); + connect( config, &Config::messageChanged, headerLabel, &QLabel::setText ); + layout->addWidget( headerLabel ); + layout->addWidget( m_scrollArea ); + m_scrollArea->setWidgetResizable( true ); + m_scrollArea->setHorizontalScrollBarPolicy( Qt::ScrollBarAlwaysOff ); + // If Calamares will grow, then only show scrollbar when it's needed + // (e.g. when the screen is full). + m_scrollArea->setVerticalScrollBarPolicy( + Calamares::Branding::instance()->windowExpands() ? Qt::ScrollBarAsNeeded : Qt::ScrollBarAlwaysOn ); + m_scrollArea->setFrameStyle( QFrame::NoFrame ); + m_scrollArea->setContentsMargins( 0, 0, 0, 0 ); +} + +static QLabel* +createTitleLabel( const QString& text, const QFont& titleFont ) +{ + QLabel* label = new QLabel( text ); + label->setObjectName( "summaryItemTitle" ); + label->setFont( titleFont ); + label->setContentsMargins( 0, 0, 0, 0 ); + + return label; +} + +static QLabel* +createBodyLabel( const QString& text, const QPalette& bodyPalette ) +{ + QLabel* label = new QLabel; + label->setObjectName( "summaryItemBody" ); + label->setMargin( Calamares::defaultFontHeight() / 2 ); + label->setAutoFillBackground( true ); + label->setPalette( bodyPalette ); + label->setText( text ); + return label; +} + +static QWidget* +createStepWidget( const QString& description, QWidget* innerWidget, const QPalette& palette ) +{ + QWidget* w = new QWidget(); + QHBoxLayout* itemBodyLayout = new QHBoxLayout; + w->setLayout( itemBodyLayout ); + + // Indent the inner box by a bit + itemBodyLayout->addSpacing( Calamares::defaultFontHeight() * 2 ); + QVBoxLayout* itemBodyCoreLayout = new QVBoxLayout; + itemBodyLayout->addLayout( itemBodyCoreLayout ); + Calamares::unmarginLayout( itemBodyLayout ); + + itemBodyCoreLayout->addSpacing( Calamares::defaultFontHeight() / 2 ); + if ( innerWidget ) + { + itemBodyCoreLayout->addWidget( innerWidget ); + } + else + { + itemBodyCoreLayout->addWidget( createBodyLabel( description, palette ) ); + } + + return w; +} + +static void +ensureSize( QWidget* parent, QScrollArea* container, Calamares::ViewStep* viewstep ) +{ + auto summarySize = container->widget()->sizeHint(); + if ( summarySize.height() > container->size().height() ) + { + auto enlarge = 2 + summarySize.height() - container->size().height(); + auto widgetSize = parent->size(); + widgetSize.setHeight( widgetSize.height() + enlarge ); + + cDebug() << "Summary widget is larger than viewport, enlarge by" << enlarge << "to" << widgetSize; + + emit viewstep->ensureSize( widgetSize ); // Only expand height + } +} + +// Adds a widget for those ViewSteps that want a summary; +// see SummaryPage documentation and also ViewStep docs. +void +SummaryPage::buildWidgets( Config* config, SummaryViewStep* viewstep ) +{ + const int SECTION_SPACING = 12; + + delete m_contentWidget; // It might have been created previously + m_contentWidget = new QWidget; + m_layout = new QVBoxLayout( m_contentWidget ); + Calamares::unmarginLayout( m_layout ); + + QFont titleFont = font(); + titleFont.setWeight( QFont::Light ); + titleFont.setPointSize( Calamares::defaultFontSize() * 2 ); + + QPalette bodyPalette( palette() ); + bodyPalette.setColor( WindowBackground, palette().window().color().lighter( 108 ) ); + + const auto* model = config->summaryModel(); + const auto rowCount = model->rowCount(); + + for ( int row = 0; row < rowCount; row++ ) + { + const auto rowIndex = model->index( row ); + QString title = model->data( rowIndex, SummaryModel::TitleRole ).toString(); + QString text = model->data( rowIndex, SummaryModel::MessageRole ).toString(); + QWidget* widget = model->data( rowIndex, SummaryModel::WidgetRole ).value< QWidget* >(); + + if ( text.isEmpty() && !widget ) + { + continue; + } + + if ( row > 0 ) + { + m_layout->addSpacing( SECTION_SPACING ); + } + + m_layout->addWidget( createTitleLabel( title, titleFont ) ); + m_layout->addWidget( createStepWidget( text, widget, bodyPalette ) ); + } + m_layout->addStretch(); + + m_scrollArea->setWidget( m_contentWidget ); + ensureSize( this, m_scrollArea, viewstep ); +} + +void +SummaryPage::cleanup() +{ + delete m_contentWidget; + m_contentWidget = nullptr; +} diff --git a/calamares/src/modules/summary/SummaryPage.h b/calamares/src/modules/summary/SummaryPage.h new file mode 100644 index 0000000..9976020 --- /dev/null +++ b/calamares/src/modules/summary/SummaryPage.h @@ -0,0 +1,61 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SUMMARYPAGE_H +#define SUMMARYPAGE_H + +#include "viewpages/ViewStep.h" + +#include + +class Config; +class SummaryViewStep; + +class QLabel; +class QScrollArea; +class QVBoxLayout; + + +/** @brief Provide a summary view with to-be-done action descriptions. +* +* Those steps that occur since the previous execution step (e.g. that +* are queued for execution now; in the normal case where there is +* only one execution step, this means everything that the installer +* is going to do) are added to the summary view. Each view step +* can provide one of the following things to display in the summary +* view: +* +* - A string from ViewStep::prettyStatus(), which is formatted +* and added as a QLabel to the view. Return an empty string +* from prettyStatus() to avoid this. +* - A QWidget from ViewStep::createSummaryWidget(). This is for +* complicated displays not suitable for simple text representation. +* Return a nullptr to avoid this. +* +* If neither a (non-empty) string nor a widget is returned, the +* step is not named in the summary. +*/ +class SummaryPage : public QWidget +{ + Q_OBJECT +public: + explicit SummaryPage( Config* config, QWidget* parent = nullptr ); + + /// @brief Create contents showing all of the summary + void buildWidgets( Config* config, SummaryViewStep* viewstep ); + /// @brief Clean up the widgets + void cleanup(); + +private: + QVBoxLayout* m_layout = nullptr; + QWidget* m_contentWidget = nullptr; + QScrollArea* m_scrollArea = nullptr; +}; + +#endif // SUMMARYPAGE_H diff --git a/calamares/src/modules/summary/SummaryViewStep.cpp b/calamares/src/modules/summary/SummaryViewStep.cpp new file mode 100644 index 0000000..9d63d0d --- /dev/null +++ b/calamares/src/modules/summary/SummaryViewStep.cpp @@ -0,0 +1,97 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SummaryViewStep.h" + +#include "SummaryPage.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( SummaryViewStepFactory, registerPlugin< SummaryViewStep >(); ) + +SummaryViewStep::SummaryViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_config( new Config( this ) ) + , m_widget( new SummaryPage( m_config ) ) +{ + emit nextStatusChanged( true ); +} + + +SummaryViewStep::~SummaryViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } + delete m_config; +} + + +QString +SummaryViewStep::prettyName() const +{ + return m_config->title(); +} + + +QWidget* +SummaryViewStep::widget() +{ + return m_widget; +} + + +bool +SummaryViewStep::isNextEnabled() const +{ + return true; +} + + +bool +SummaryViewStep::isBackEnabled() const +{ + return true; +} + + +bool +SummaryViewStep::isAtBeginning() const +{ + return true; +} + + +bool +SummaryViewStep::isAtEnd() const +{ + return true; +} + + +Calamares::JobList +SummaryViewStep::jobs() const +{ + return {}; +} + + +void +SummaryViewStep::onActivate() +{ + m_config->collectSummaries( this, Config::Widgets::Enabled ); + m_widget->buildWidgets( m_config, this ); +} + + +void +SummaryViewStep::onLeave() +{ + m_config->clearSummaries(); + m_widget->cleanup(); +} diff --git a/calamares/src/modules/summary/SummaryViewStep.h b/calamares/src/modules/summary/SummaryViewStep.h new file mode 100644 index 0000000..e2ee056 --- /dev/null +++ b/calamares/src/modules/summary/SummaryViewStep.h @@ -0,0 +1,51 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SUMMARYPAGEPLUGIN_H +#define SUMMARYPAGEPLUGIN_H + +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +class SummaryPage; + +class PLUGINDLLEXPORT SummaryViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit SummaryViewStep( QObject* parent = nullptr ); + ~SummaryViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onActivate() override; + void onLeave() override; + +private: + Config* m_config = nullptr; + SummaryPage* m_widget = nullptr; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( SummaryViewStepFactory ) + +#endif // SUMMARYPAGEPLUGIN_H diff --git a/calamares/src/modules/summaryq/CMakeLists.txt b/calamares/src/modules/summaryq/CMakeLists.txt new file mode 100644 index 0000000..75dd68a --- /dev/null +++ b/calamares/src/modules/summaryq/CMakeLists.txt @@ -0,0 +1,28 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2021 Anke Boersma +# SPDX-License-Identifier: BSD-2-Clause +# +if(NOT WITH_QML) + calamares_skip_module( "summaryq (QML is not supported in this build)" ) + return() +endif() + +set(_summary ${CMAKE_CURRENT_SOURCE_DIR}/../summary) +include_directories(${_summary}) + +calamares_add_plugin(summaryq + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + SummaryQmlViewStep.cpp + ${_summary}/Config.cpp + ${_summary}/SummaryModel.cpp + UI + RESOURCES + summaryq${QT_VERSION_SUFFIX}.qrc + LINK_PRIVATE_LIBRARIES + calamaresui + SHARED_LIB + NO_CONFIG +) diff --git a/calamares/src/modules/summaryq/SummaryQmlViewStep.cpp b/calamares/src/modules/summaryq/SummaryQmlViewStep.cpp new file mode 100644 index 0000000..a5acdfd --- /dev/null +++ b/calamares/src/modules/summaryq/SummaryQmlViewStep.cpp @@ -0,0 +1,73 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015, Teo Mrnjavac + * SPDX-FileCopyrightText: 2020, Camilo Higuita + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SummaryQmlViewStep.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( SummaryQmlViewStepFactory, registerPlugin< SummaryQmlViewStep >(); ) + +SummaryQmlViewStep::SummaryQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) + , m_config( new Config( this ) ) +{ + emit nextStatusChanged( true ); +} + + +SummaryQmlViewStep::~SummaryQmlViewStep() {} + +QString +SummaryQmlViewStep::prettyName() const +{ + return m_config->title(); +} + + +bool +SummaryQmlViewStep::isNextEnabled() const +{ + return true; +} + + +bool +SummaryQmlViewStep::isBackEnabled() const +{ + return true; +} + + +bool +SummaryQmlViewStep::isAtBeginning() const +{ + return true; +} + + +bool +SummaryQmlViewStep::isAtEnd() const +{ + return true; +} + + +Calamares::JobList +SummaryQmlViewStep::jobs() const +{ + return {}; +} + + +void +SummaryQmlViewStep::onActivate() +{ + // Collect the steps before this one: those need to have their + // summary (text or widget) displayed. + m_config->collectSummaries( this, Config::Widgets::Disabled ); +} diff --git a/calamares/src/modules/summaryq/SummaryQmlViewStep.h b/calamares/src/modules/summaryq/SummaryQmlViewStep.h new file mode 100644 index 0000000..8668d0a --- /dev/null +++ b/calamares/src/modules/summaryq/SummaryQmlViewStep.h @@ -0,0 +1,49 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015, Teo Mrnjavac + * SPDX-FileCopyrightText: 2020, Camilo Higuita + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SUMMARYQMLVIEWSTEP_H +#define SUMMARYQMLVIEWSTEP_H + +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/QmlViewStep.h" + +class PLUGINDLLEXPORT SummaryQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + explicit SummaryQmlViewStep( QObject* parent = nullptr ); + virtual ~SummaryQmlViewStep() override; + + QString prettyName() const override; + + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onActivate() override; + + QObject* getConfig() override { return m_config; } + +private: + Config* m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( SummaryQmlViewStepFactory ) + +#endif // SUMMARYQMLVIEWSTEP_H diff --git a/calamares/src/modules/summaryq/img/keyboard.svg b/calamares/src/modules/summaryq/img/keyboard.svg new file mode 100644 index 0000000..6227b78 --- /dev/null +++ b/calamares/src/modules/summaryq/img/keyboard.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/summaryq/img/keyboard.svg.license b/calamares/src/modules/summaryq/img/keyboard.svg.license new file mode 100644 index 0000000..e59dc6f --- /dev/null +++ b/calamares/src/modules/summaryq/img/keyboard.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021 KDE Visual Design Group +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/summaryq/img/lokalize.svg b/calamares/src/modules/summaryq/img/lokalize.svg new file mode 100644 index 0000000..83a7c9d --- /dev/null +++ b/calamares/src/modules/summaryq/img/lokalize.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/summaryq/img/lokalize.svg.license b/calamares/src/modules/summaryq/img/lokalize.svg.license new file mode 100644 index 0000000..e59dc6f --- /dev/null +++ b/calamares/src/modules/summaryq/img/lokalize.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021 KDE Visual Design Group +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/summaryq/summaryq-qt6.qml b/calamares/src/modules/summaryq/summaryq-qt6.qml new file mode 100644 index 0000000..22dac58 --- /dev/null +++ b/calamares/src/modules/summaryq/summaryq-qt6.qml @@ -0,0 +1,111 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import QtQuick.Window + +Kirigami.ScrollablePage { + width: 860 //parent.width + height: 640 //parent.height + + Kirigami.Theme.backgroundColor: "#EFF0F1" + Kirigami.Theme.textColor: "#1F1F1F" + + header: Kirigami.Heading { + Layout.fillWidth: true + height: 100 + horizontalAlignment: Qt.AlignHCenter + color: Kirigami.Theme.textColor + font.weight: Font.Medium + font.pointSize: 12 + text: config.message + + } + + RowLayout { + width: parent.width + + Component { + id: _delegate + + Rectangle { + id: rect + border.color: "#BDC3C7" + width: parent.width - 80 + implicitHeight: message.implicitHeight + title.implicitHeight + 20 + anchors.horizontalCenter: parent.horizontalCenter + + Item { + width: parent.width - 80 + implicitHeight: message.implicitHeight + title.implicitHeight + 20 + + Kirigami.FormLayout { + + GridLayout { + anchors { + //left: parent.left + top: parent.top + right: parent.right + } + rowSpacing: Kirigami.Units.largeSpacing + columnSpacing: Kirigami.Units.largeSpacing + columns: width > Kirigami.Units.gridUnit * 20 ? 4 : 2 + + Image { + id: image + Layout.maximumHeight: Kirigami.Units.iconSizes.huge + Layout.preferredWidth: height + Layout.alignment: Qt.AlignTop + fillMode: Image.PreserveAspectFit + source: index === 0 ? "img/lokalize.svg" + : ( index === 1 ? "img/keyboard.svg" + : ( index === 2 ? "qrc:/data/images/partition-manual.svg" + : "qrc:/data/images/partition-partition.svg" ) ) + } + ColumnLayout { + + Label { + id: title + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: model.title + font.weight: Font.Medium + font.pointSize: 16 + } + Rectangle { + height: 2 + width: 200 + border.color: "#BDC3C7" + } + Label { + id: message + Layout.fillWidth: true + text: model.message + } + } + } + } + } + } + } + } + + ListView { + anchors.fill: parent + spacing: 20 + model: config.summaryModel + delegate: _delegate + } +} diff --git a/calamares/src/modules/summaryq/summaryq-qt6.qrc b/calamares/src/modules/summaryq/summaryq-qt6.qrc new file mode 100644 index 0000000..c2cfc07 --- /dev/null +++ b/calamares/src/modules/summaryq/summaryq-qt6.qrc @@ -0,0 +1,7 @@ + + + summaryq-qt6.qml + img/keyboard.svg + img/lokalize.svg + + diff --git a/calamares/src/modules/summaryq/summaryq.qml b/calamares/src/modules/summaryq/summaryq.qml new file mode 100644 index 0000000..626a42c --- /dev/null +++ b/calamares/src/modules/summaryq/summaryq.qml @@ -0,0 +1,112 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.15 +import QtQuick.Controls 2.13 +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.7 as Kirigami +import QtGraphicalEffects 1.0 +import QtQuick.Window 2.3 + +Kirigami.ScrollablePage { + width: 860 //parent.width //860 + height: 640 //parent.height //640 + + Kirigami.Theme.backgroundColor: "#EFF0F1" + Kirigami.Theme.textColor: "#1F1F1F" + + header: Kirigami.Heading { + Layout.fillWidth: true + height: 100 + horizontalAlignment: Qt.AlignHCenter + color: Kirigami.Theme.textColor + font.weight: Font.Medium + font.pointSize: 12 + text: config.message + + } + + RowLayout { + width: parent.width + + Component { + id: _delegate + + Rectangle { + id: rect + border.color: "#BDC3C7" + width: parent.width - 80 + implicitHeight: message.implicitHeight + title.implicitHeight + 20 + anchors.horizontalCenter: parent.horizontalCenter + + Item { + width: parent.width - 80 + implicitHeight: message.implicitHeight + title.implicitHeight + 20 + + Kirigami.FormLayout { + + GridLayout { + anchors { + //left: parent.left + top: parent.top + right: parent.right + } + rowSpacing: Kirigami.Units.largeSpacing + columnSpacing: Kirigami.Units.largeSpacing + columns: width > Kirigami.Units.gridUnit * 20 ? 4 : 2 + + Image { + id: image + Layout.maximumHeight: Kirigami.Units.iconSizes.huge + Layout.preferredWidth: height + Layout.alignment: Qt.AlignTop + fillMode: Image.PreserveAspectFit + source: index === 0 ? "img/lokalize.svg" + : ( index === 1 ? "img/keyboard.svg" + : ( index === 2 ? "qrc:/data/images/partition-manual.svg" + : "qrc:/data/images/partition-partition.svg" ) ) + } + ColumnLayout { + + Label { + id: title + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: model.title + font.weight: Font.Medium + font.pointSize: 16 + } + Rectangle { + height: 2 + width: 200 + border.color: "#BDC3C7" + } + Label { + id: message + Layout.fillWidth: true + text: model.message + } + } + } + } + } + } + } + } + + ListView { + anchors.fill: parent + spacing: 20 + model: config.summaryModel + delegate: _delegate + } +} diff --git a/calamares/src/modules/summaryq/summaryq.qrc b/calamares/src/modules/summaryq/summaryq.qrc new file mode 100644 index 0000000..62bfe78 --- /dev/null +++ b/calamares/src/modules/summaryq/summaryq.qrc @@ -0,0 +1,7 @@ + + + summaryq.qml + img/keyboard.svg + img/lokalize.svg + + diff --git a/calamares/src/modules/tracking/CMakeLists.txt b/calamares/src/modules/tracking/CMakeLists.txt new file mode 100644 index 0000000..b54bd14 --- /dev/null +++ b/calamares/src/modules/tracking/CMakeLists.txt @@ -0,0 +1,23 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(tracking + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + Config.cpp + TrackingJobs.cpp + TrackingPage.cpp + TrackingViewStep.cpp + UI + page_trackingstep.ui + RESOURCES + page_trackingstep.qrc + SHARED_LIB + LINK_LIBRARIES + ${kfname}::CoreAddons +) + +calamares_add_test(trackingtest SOURCES Tests.cpp Config.cpp) diff --git a/calamares/src/modules/tracking/Config.cpp b/calamares/src/modules/tracking/Config.cpp new file mode 100644 index 0000000..58c4f63 --- /dev/null +++ b/calamares/src/modules/tracking/Config.cpp @@ -0,0 +1,250 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "TrackingType.h" + +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include + +const NamedEnumTable< TrackingType >& +trackingNames() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< TrackingType > names { + { QStringLiteral( "none" ), TrackingType::NoTracking }, + { QStringLiteral( "install" ), TrackingType::InstallTracking }, + { QStringLiteral( "machine" ), TrackingType::MachineTracking }, + { QStringLiteral( "user" ), TrackingType::UserTracking } + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +TrackingStyleConfig::TrackingStyleConfig( QObject* parent ) + : QObject( parent ) +{ +} + +TrackingStyleConfig::~TrackingStyleConfig() {} + +void +TrackingStyleConfig::setTracking( bool enabled ) +{ + setTracking( enabled ? EnabledByUser : DisabledByUser ); +} + +void +TrackingStyleConfig::setTracking( TrackingStyleConfig::TrackingState state ) +{ + if ( m_state != TrackingState::DisabledByConfig ) + { + m_state = state; + } + emit trackingChanged(); +} + +void +TrackingStyleConfig::validate( QString& s, std::function< bool( const QString& ) >&& pred ) +{ + if ( !pred( s ) ) + { + if ( m_state != DisabledByConfig ) + { + cError() << "Configuration string" << s << "is not valid; disabling this tracking type."; + m_state = DisabledByConfig; + emit trackingChanged(); + } + s = QString(); + } +} + +void +TrackingStyleConfig::validateUrl( QString& urlString ) +{ + if ( !QUrl( urlString ).isValid() ) + { + if ( m_state != DisabledByConfig ) + { + cError() << "URL" << urlString << "is not valid; disabling tracking type" << objectName(); + m_state = DisabledByConfig; + emit trackingChanged(); + } + urlString = QString(); + } +} + +void +TrackingStyleConfig::setConfigurationMap( const QVariantMap& config ) +{ + m_state = Calamares::getBool( config, "enabled", false ) ? DisabledByUser : DisabledByConfig; + m_policy = Calamares::getString( config, "policy" ); + validateUrl( m_policy ); + emit policyChanged( m_policy ); + emit trackingChanged(); +} + +InstallTrackingConfig::InstallTrackingConfig( QObject* parent ) + : TrackingStyleConfig( parent ) +{ + setObjectName( "InstallTrackingConfig" ); +} + +InstallTrackingConfig::~InstallTrackingConfig() {} + +void +InstallTrackingConfig::setConfigurationMap( const QVariantMap& configurationMap ) +{ + TrackingStyleConfig::setConfigurationMap( configurationMap ); + + m_installTrackingUrl = Calamares::getString( configurationMap, "url" ); + validateUrl( m_installTrackingUrl ); +} + +MachineTrackingConfig::MachineTrackingConfig( QObject* parent ) + : TrackingStyleConfig( parent ) +{ + setObjectName( "MachineTrackingConfig" ); +} + +MachineTrackingConfig::~MachineTrackingConfig() {} + +/** @brief Is @p s a valid machine-tracking style. */ +static bool +isValidMachineTrackingStyle( const QString& s ) +{ + static QStringList knownStyles { "updatemanager" }; + return knownStyles.contains( s ); +} + +void +MachineTrackingConfig::setConfigurationMap( const QVariantMap& configurationMap ) +{ + TrackingStyleConfig::setConfigurationMap( configurationMap ); + + m_machineTrackingStyle = Calamares::getString( configurationMap, "style" ); + validate( m_machineTrackingStyle, isValidMachineTrackingStyle ); +} + +UserTrackingConfig::UserTrackingConfig( QObject* parent ) + : TrackingStyleConfig( parent ) +{ + setObjectName( "UserTrackingConfig" ); +} + +UserTrackingConfig::~UserTrackingConfig() {} + +static bool +isValidUserTrackingStyle( const QString& s ) +{ + static QStringList knownStyles { "kuserfeedback" }; + return knownStyles.contains( s ); +} + +void +UserTrackingConfig::setConfigurationMap( const QVariantMap& configurationMap ) +{ + TrackingStyleConfig::setConfigurationMap( configurationMap ); + + m_userTrackingStyle = Calamares::getString( configurationMap, "style" ); + validate( m_userTrackingStyle, isValidUserTrackingStyle ); + + m_userTrackingAreas = Calamares::getStringList( configurationMap, "areas" ); +} + +Config::Config( QObject* parent ) + : QObject( parent ) + , m_installTracking( new InstallTrackingConfig( this ) ) + , m_machineTracking( new MachineTrackingConfig( this ) ) + , m_userTracking( new UserTrackingConfig( this ) ) +{ +} + +static void +enableLevelsBelow( Config* config, TrackingType level ) +{ + switch ( level ) + { + case TrackingType::UserTracking: + config->userTracking()->setTracking( TrackingStyleConfig::TrackingState::EnabledByUser ); + [[fallthrough]]; + case TrackingType::MachineTracking: + config->machineTracking()->setTracking( TrackingStyleConfig::TrackingState::EnabledByUser ); + [[fallthrough]]; + case TrackingType::InstallTracking: + config->installTracking()->setTracking( TrackingStyleConfig::TrackingState::EnabledByUser ); + break; + case TrackingType::NoTracking: + config->noTracking( true ); + break; + } +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_generalPolicy = Calamares::getString( configurationMap, "policy" ); + + if ( !QUrl( m_generalPolicy ).isValid() ) + { + m_generalPolicy = QString(); + } + emit generalPolicyChanged( m_generalPolicy ); + + bool success = false; + auto subconfig = Calamares::getSubMap( configurationMap, "install", success ); + if ( success ) + { + m_installTracking->setConfigurationMap( subconfig ); + } + + subconfig = Calamares::getSubMap( configurationMap, "machine", success ); + if ( success ) + { + m_machineTracking->setConfigurationMap( subconfig ); + } + + subconfig = Calamares::getSubMap( configurationMap, "user", success ); + if ( success ) + { + m_userTracking->setConfigurationMap( subconfig ); + } + + auto level = trackingNames().find( Calamares::getString( configurationMap, "default" ), success ); + if ( !success ) + { + cWarning() << "Default tracking level unknown:" << Calamares::getString( configurationMap, "default" ); + level = TrackingType::NoTracking; + } + enableLevelsBelow( this, level ); +} + +QString +Config::generalPolicy() const +{ + return m_generalPolicy; +} + +void +Config::noTracking( bool switchOffAllTracking ) +{ + if ( !switchOffAllTracking ) + { + return; + } + m_installTracking->setTracking( TrackingStyleConfig::TrackingState::DisabledByUser ); + m_machineTracking->setTracking( TrackingStyleConfig::TrackingState::DisabledByUser ); + m_userTracking->setTracking( TrackingStyleConfig::TrackingState::DisabledByUser ); +} diff --git a/calamares/src/modules/tracking/Config.h b/calamares/src/modules/tracking/Config.h new file mode 100644 index 0000000..c91d430 --- /dev/null +++ b/calamares/src/modules/tracking/Config.h @@ -0,0 +1,186 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TRACKING_CONFIG_H +#define TRACKING_CONFIG_H + +#include +#include +#include + +/** @brief Base class for configuring a specific kind of tracking. + * + * All tracking types have a policy URL, which is used to explain what + * kind of tracking is involved, what data is sent, etc. The content + * of that URL is the responsibility of the distro. + * + * A tracking type is disabled by default: if it isn't specifically + * enabled (for configuration) in the config file, it will always be disabled. + * If it is enabled (for configuration) in the config file, it still + * defaults to disabled, but the user can choose to enable it. + */ +class TrackingStyleConfig : public QObject +{ + Q_OBJECT + + Q_PROPERTY( TrackingState trackingStatus READ tracking WRITE setTracking NOTIFY trackingChanged FINAL ) + Q_PROPERTY( bool isEnabled READ isEnabled NOTIFY trackingChanged FINAL ) + Q_PROPERTY( bool isConfigurable READ isConfigurable NOTIFY trackingChanged FINAL ) + Q_PROPERTY( QString policy READ policy NOTIFY policyChanged FINAL ) + +public: + TrackingStyleConfig( QObject* parent ); + ~TrackingStyleConfig() override; + + void setConfigurationMap( const QVariantMap& ); + + enum TrackingState + { + DisabledByConfig, + DisabledByUser, + EnabledByUser + }; + Q_ENUM( TrackingState ) + +public Q_SLOTS: + TrackingState tracking() const { return m_state; } + /// @brief Has the user specifically enabled this kind of tracking? + bool isEnabled() const { return m_state == EnabledByUser; } + /// @brief Is this tracking enabled for configuration? + bool isConfigurable() const { return m_state != DisabledByConfig; } + /** @brief Sets the tracking state + * + * Unless the tracking is enabled for configuration, it always + * remains disabled. + */ + void setTracking( TrackingState ); + /** @brief Sets the tracking state + * + * Use @c true for @c EnabledByUser, @c false for DisabledByUser, + * but keep in mind that if the tracking is not enabled for + * configuration, it will always remain disabled. + */ + void setTracking( bool ); + + /// @brief URL for the policy explaining this tracking + QString policy() const { return m_policy; } + +signals: + void trackingChanged(); + void policyChanged( QString ); + +protected: + /// @brief Validates the @p urlString, disables tracking if invalid + void validateUrl( QString& urlString ); + /// @brief Validates the @p string, disables tracking if invalid + void validate( QString& s, std::function< bool( const QString& s ) >&& pred ); + +private: + TrackingState m_state = DisabledByConfig; + QString m_policy; // URL +}; + +/** @brief Install tracking pings a URL at the end of installation + * + * Install tracking will do a single GET on the given URL at + * the end of installation. The information included in the GET + * request depends on the URL configuration, see also the tracking + * jobs. + */ +class InstallTrackingConfig : public TrackingStyleConfig +{ +public: + InstallTrackingConfig( QObject* parent ); + ~InstallTrackingConfig() override; + void setConfigurationMap( const QVariantMap& configurationMap ); + + QString installTrackingUrl() { return m_installTrackingUrl; } + +private: + QString m_installTrackingUrl; +}; + +/** @brief Machine tracking reports from the installed system + * + * When machine tracking is on, the installed system will report + * back ("call home") at some point. This can mean Debian pop-con, + * or updatemanager maching tracking, or something else. The kind + * of configuration depends on the style of tracking that is enabled. + */ +class MachineTrackingConfig : public TrackingStyleConfig +{ +public: + MachineTrackingConfig( QObject* parent ); + ~MachineTrackingConfig() override; + void setConfigurationMap( const QVariantMap& configurationMap ); + + QString machineTrackingStyle() { return m_machineTrackingStyle; } + +private: + QString m_machineTrackingStyle; +}; + +/** @brief User tracking reports user actions + * + * When user tracking is on, it is enabled for the user configured + * in Calamares -- not for users created afterwards in the target + * system, unless the target system defaults to tracking them. + * The kind of user tracking depends on the target system and + * environment; KDE user tracking is one example, which can be + * configured in a fine-grained way and defaults to off. + */ +class UserTrackingConfig : public TrackingStyleConfig +{ +public: + UserTrackingConfig( QObject* parent ); + ~UserTrackingConfig() override; + void setConfigurationMap( const QVariantMap& configurationMap ); + + QString userTrackingStyle() { return m_userTrackingStyle; } + QStringList userTrackingAreas() const { return m_userTrackingAreas; } + +private: + QString m_userTrackingStyle; + QStringList m_userTrackingAreas; // fine-grained areas +}; + +class Config : public QObject +{ + Q_OBJECT + Q_PROPERTY( QString generalPolicy READ generalPolicy NOTIFY generalPolicyChanged FINAL ) + Q_PROPERTY( TrackingStyleConfig* installTracking READ installTracking FINAL ) + Q_PROPERTY( TrackingStyleConfig* machineTracking READ machineTracking FINAL ) + Q_PROPERTY( TrackingStyleConfig* userTracking READ userTracking FINAL ) + +public: + Config( QObject* parent = nullptr ); + void setConfigurationMap( const QVariantMap& ); + +public Q_SLOTS: + QString generalPolicy() const; + + InstallTrackingConfig* installTracking() const { return m_installTracking; } + MachineTrackingConfig* machineTracking() const { return m_machineTracking; } + UserTrackingConfig* userTracking() const { return m_userTracking; } + + /// @brief Call with @c true to turn off all the trackings + void noTracking( bool ); + +signals: + void generalPolicyChanged( QString ); + +private: + QString m_generalPolicy; + + InstallTrackingConfig* m_installTracking; + MachineTrackingConfig* m_machineTracking; + UserTrackingConfig* m_userTracking; +}; + +#endif diff --git a/calamares/src/modules/tracking/Tests.cpp b/calamares/src/modules/tracking/Tests.cpp new file mode 100644 index 0000000..c3fe90a --- /dev/null +++ b/calamares/src/modules/tracking/Tests.cpp @@ -0,0 +1,59 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "Config.h" + +#include "utils/Logger.h" + +#include +#include + +class TrackingTests : public QObject +{ + Q_OBJECT +public: + TrackingTests(); + ~TrackingTests() override; + +private Q_SLOTS: + void initTestCase(); + void testEmptyConfig(); +}; + +TrackingTests::TrackingTests() + : QObject() +{ +} + +TrackingTests::~TrackingTests() {} + +void +TrackingTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "Tracking test started."; +} + +void +TrackingTests::testEmptyConfig() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + + Config* c = new Config; + QVERIFY( c->generalPolicy().isEmpty() ); + QVERIFY( c->installTracking() ); // not-nullptr + + cDebug() << "Install" << Logger::Pointer( c->installTracking() ); + + delete c; // also deletes the owned tracking-configs +} + + +QTEST_GUILESS_MAIN( TrackingTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/tracking/TrackingJobs.cpp b/calamares/src/modules/tracking/TrackingJobs.cpp new file mode 100644 index 0000000..53a97b7 --- /dev/null +++ b/calamares/src/modules/tracking/TrackingJobs.cpp @@ -0,0 +1,301 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "TrackingJobs.h" + +#include "Config.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "network/Manager.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include + +#include + +#include + +// Namespace keeps all the actual jobs anonymous, the +// public API is the addJob() functions below the namespace. +namespace +{ + +/** @brief Install-tracking job (gets a URL) + * + * The install-tracking job (there is only one kind) does a GET + * on a configured URL with some additional information about + * the machine (if configured into the URL). + * + * No persistent tracking is done. + */ +class TrackingInstallJob : public Calamares::Job +{ +public: + TrackingInstallJob( const QString& url ); + ~TrackingInstallJob() override; + + QString prettyName() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + const QString m_url; +}; + +/** @brief Tracking machines, update-manager style + * + * The machine has a machine-id, and this is sed(1)'ed into the + * update-manager configuration, to report the machine-id back + * to distro servers. + */ +class TrackingMachineUpdateManagerJob : public Calamares::Job +{ +public: + ~TrackingMachineUpdateManagerJob() override; + + QString prettyName() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; +}; + +/** @brief Turn on KUserFeedback in target system + * + * This writes suitable files for turning on KUserFeedback for the + * normal user configured in Calamares. The feedback can be reconfigured + * by the user through Plasma's user-feedback dialog. + */ +class TrackingKUserFeedbackJob : public Calamares::Job +{ +public: + TrackingKUserFeedbackJob( const QString& username, const QStringList& areas ); + ~TrackingKUserFeedbackJob() override; + + QString prettyName() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + QString m_username; + QStringList m_areas; +}; + +TrackingInstallJob::TrackingInstallJob( const QString& url ) + : m_url( url ) +{ +} + +TrackingInstallJob::~TrackingInstallJob() {} + +QString +TrackingInstallJob::prettyName() const +{ + return QCoreApplication::translate( "TrackingInstallJob", "Installation feedback" ); +} + +QString +TrackingInstallJob::prettyStatusMessage() const +{ + return QCoreApplication::translate( "TrackingInstallJob", "Sending installation feedback…", "@status" ); +} + +Calamares::JobResult +TrackingInstallJob::exec() +{ + using Calamares::Network::Manager; + using Calamares::Network::RequestOptions; + using Calamares::Network::RequestStatus; + + auto result + = Manager().synchronousPing( QUrl( m_url ), + RequestOptions( RequestOptions::FollowRedirect | RequestOptions::FakeUserAgent, + RequestOptions::milliseconds( 5000 ) ) ); + if ( result.status == RequestStatus::Timeout ) + { + cWarning() << "install-tracking request timed out."; + return Calamares::JobResult::error( + QCoreApplication::translate( "TrackingInstallJob", "Internal error in install-tracking." ), + QCoreApplication::translate( "TrackingInstallJob", "HTTP request timed out." ) ); + } + return Calamares::JobResult::ok(); +} + +TrackingMachineUpdateManagerJob::~TrackingMachineUpdateManagerJob() {} + +QString +TrackingMachineUpdateManagerJob::prettyName() const +{ + return QCoreApplication::translate( "TrackingMachineUpdateManagerJob", "Machine feedback" ); +} + +QString +TrackingMachineUpdateManagerJob::prettyStatusMessage() const +{ + return QCoreApplication::translate( "TrackingMachineUpdateManagerJob", "Configuring machine feedback…", "@status" ); +} + +Calamares::JobResult +TrackingMachineUpdateManagerJob::exec() +{ + static const auto script = QStringLiteral( + "sed -i '/^URI/s,${MACHINE_ID},'`cat /etc/machine-id`',' /etc/update-manager/meta-release || true" ); + + auto res = Calamares::System::instance()->runCommand( Calamares::System::RunLocation::RunInTarget, + QStringList { QStringLiteral( "/bin/sh" ) }, + QString(), // Working dir + script, // standard input + std::chrono::seconds( 1 ) ); + int r = res.first; + + if ( r == 0 ) + { + return Calamares::JobResult::ok(); + } + else if ( r > 0 ) + { + return Calamares::JobResult::error( + QCoreApplication::translate( "TrackingMachineUpdateManagerJob", + "Error in machine feedback configuration." ), + QCoreApplication::translate( "TrackingMachineUpdateManagerJob", + "Could not configure machine feedback correctly, script error %1." ) + .arg( r ) ); + } + else + { + return Calamares::JobResult::error( + QCoreApplication::translate( "TrackingMachineUpdateManagerJob", + "Error in machine feedback configuration." ), + QCoreApplication::translate( "TrackingMachineUpdateManagerJob", + "Could not configure machine feedback correctly, Calamares error %1." ) + .arg( r ) ); + } +} + +TrackingKUserFeedbackJob::TrackingKUserFeedbackJob( const QString& username, const QStringList& areas ) + : m_username( username ) + , m_areas( areas ) +{ +} + +TrackingKUserFeedbackJob::~TrackingKUserFeedbackJob() {} + +QString +TrackingKUserFeedbackJob::prettyName() const +{ + return QCoreApplication::translate( "TrackingKUserFeedbackJob", "KDE user feedback" ); +} + +QString +TrackingKUserFeedbackJob::prettyStatusMessage() const +{ + return QCoreApplication::translate( "TrackingKUserFeedbackJob", "Configuring KDE user feedback…", "@status" ); +} + +Calamares::JobResult +TrackingKUserFeedbackJob::exec() +{ + // This is the contents of a config file to turn on some kind + // of KUserFeedback tracking; the level (16) is chosen for minimal + // but not zero tracking. + static const char config[] = R"x([Global] +FeedbackLevel=16 +)x"; + + for ( const QString& area : m_areas ) + { + QString path = QStringLiteral( "/home/%1/.config/%2" ).arg( m_username, area ); + cDebug() << "Configuring KUserFeedback" << path; + + int r = Calamares::System::instance()->createTargetFile( path, config ); + if ( r > 0 ) + { + return Calamares::JobResult::error( + QCoreApplication::translate( "TrackingKUserFeedbackJob", "Error in KDE user feedback configuration." ), + QCoreApplication::translate( "TrackingKUserFeedbackJob", + "Could not configure KDE user feedback correctly, script error %1." ) + .arg( r ) ); + } + else if ( r < 0 ) + { + return Calamares::JobResult::error( + QCoreApplication::translate( "TrackingKUserFeedbackJob", "Error in KDE user feedback configuration." ), + QCoreApplication::translate( "TrackingKUserFeedbackJob", + "Could not configure KDE user feedback correctly, Calamares error %1." ) + .arg( r ) ); + } + } + + return Calamares::JobResult::ok(); +} + +} // namespace + +void +addJob( Calamares::JobList& list, InstallTrackingConfig* config ) +{ + if ( config->isEnabled() ) + { + const auto* s = Calamares::System::instance(); + QHash< QString, QString > map { std::initializer_list< std::pair< QString, QString > > { + { QStringLiteral( "CPU" ), s->getCpuDescription() }, + { QStringLiteral( "MEMORY" ), QString::number( s->getTotalMemoryB().first ) }, + { QStringLiteral( "DISK" ), QString::number( s->getTotalDiskB() ) } } }; + QString installUrl = KMacroExpander::expandMacros( config->installTrackingUrl(), map ); + + cDebug() << Logger::SubEntry << "install-tracking URL" << installUrl; + + list.append( Calamares::job_ptr( new TrackingInstallJob( installUrl ) ) ); + } +} + +void +addJob( Calamares::JobList& list, MachineTrackingConfig* config ) +{ + if ( config->isEnabled() ) + { + const auto style = config->machineTrackingStyle(); + if ( style == "updatemanager" ) + { + list.append( Calamares::job_ptr( new TrackingMachineUpdateManagerJob() ) ); + } + else + { + cWarning() << "Unsupported machine tracking style" << style; + } + } +} + + +void +addJob( Calamares::JobList& list, UserTrackingConfig* config ) +{ + if ( config->isEnabled() ) + { + const auto* gs = Calamares::JobQueue::instance()->globalStorage(); + static const auto key = QStringLiteral( "username" ); + QString username = ( gs && gs->contains( key ) ) ? gs->value( key ).toString() : QString(); + + if ( username.isEmpty() ) + { + cWarning() << "No username is set in GlobalStorage, skipping user-tracking."; + return; + } + + const auto style = config->userTrackingStyle(); + if ( style == "kuserfeedback" ) + { + list.append( Calamares::job_ptr( new TrackingKUserFeedbackJob( username, config->userTrackingAreas() ) ) ); + } + else + { + cWarning() << "Unsupported user tracking style" << style; + } + } +} diff --git a/calamares/src/modules/tracking/TrackingJobs.h b/calamares/src/modules/tracking/TrackingJobs.h new file mode 100644 index 0000000..4a6e90c --- /dev/null +++ b/calamares/src/modules/tracking/TrackingJobs.h @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TRACKING_TRACKINGJOBS_H +#define TRACKING_TRACKINGJOBS_H + +#include "Job.h" + +class InstallTrackingConfig; +class MachineTrackingConfig; +class UserTrackingConfig; + +/** @section Tracking Jobs + * + * The tracking jobs do the actual work of configuring tracking on the + * target machine. Tracking jobs may have *styles*, variations depending + * on the distro or environment of the target system. At the root of + * each family of tracking jobs (installation, machine, user) there is + * free function `addJob()` that takes the configuration + * information from the relevant Config sub-object and optionally + * adds the right job (subclass!) to the list of jobs. + * + * There are no job-classes defined here because you need to be using the + * `addJob()` interface instead. + */ + +void addJob( Calamares::JobList& list, InstallTrackingConfig* config ); +void addJob( Calamares::JobList& list, MachineTrackingConfig* config ); +void addJob( Calamares::JobList& list, UserTrackingConfig* config ); + +#endif diff --git a/calamares/src/modules/tracking/TrackingPage.cpp b/calamares/src/modules/tracking/TrackingPage.cpp new file mode 100644 index 0000000..859abdf --- /dev/null +++ b/calamares/src/modules/tracking/TrackingPage.cpp @@ -0,0 +1,157 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "TrackingPage.h" + +#include "Config.h" +#include "ui_page_trackingstep.h" + +#include "Branding.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "ViewManager.h" +#include "compat/CheckBox.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" + +#include +#include + +TrackingPage::TrackingPage( Config* config, QWidget* parent ) + : QWidget( parent ) + , ui( new Ui::TrackingPage ) +{ + ui->setupUi( this ); + CALAMARES_RETRANSLATE_SLOT( &TrackingPage::retranslate ); + + ui->noneCheckBox->setChecked( true ); + ui->noneCheckBox->setEnabled( false ); + connect( ui->noneCheckBox, Calamares::checkBoxStateChangedSignal, this, &TrackingPage::buttonNoneChecked ); + + // Each "panel" of configuration has the same kind of setup, + // where the xButton and xCheckBox is connected to the xTracking + // configuration object; that takes macro-trickery, unfortunately. +#define trackingSetup( x ) \ + do \ + { \ + connect( ui->x##CheckBox, Calamares::checkBoxStateChangedSignal, this, &TrackingPage::buttonChecked ); \ + connect( ui->x##CheckBox, \ + Calamares::checkBoxStateChangedSignal, \ + config->x##Tracking(), \ + QOverload< bool >::of( &TrackingStyleConfig::setTracking ) ); \ + connect( config->x##Tracking(), \ + &TrackingStyleConfig::trackingChanged, \ + this, \ + [ this, config ]() \ + { this->trackerChanged( config->x##Tracking(), this->ui->x##Group, this->ui->x##CheckBox ); } ); \ + connect( ui->x##PolicyButton, \ + &QAbstractButton::clicked, \ + config, \ + [ config ] \ + { \ + QString url( config->x##Tracking()->policy() ); \ + if ( !url.isEmpty() ) \ + { \ + QDesktopServices::openUrl( url ); \ + } \ + } ); \ + } while ( false ) + + trackingSetup( install ); + trackingSetup( machine ); + trackingSetup( user ); + +#undef trackingSetup + + connect( config, + &Config::generalPolicyChanged, + [ this ]( const QString& url ) { this->ui->generalPolicyLabel->setVisible( !url.isEmpty() ); } ); + connect( ui->generalPolicyLabel, + &QLabel::linkActivated, + [ config ] + { + QString url( config->generalPolicy() ); + if ( !url.isEmpty() ) + { + QDesktopServices::openUrl( url ); + } + } ); + + retranslate(); +} + +void +TrackingPage::retranslate() +{ + QString product = Calamares::Branding::instance()->shortProductName(); + ui->retranslateUi( this ); + ui->generalExplanation->setText( + tr( "Tracking helps %1 to see how often it is installed, what hardware it is installed on and " + "which applications are used. To see what " + "will be sent, please click the help icon next to each area." ) + .arg( product ) ); + ui->installExplanation->setText( + tr( "By selecting this you will send information about your installation and hardware. This information " + "will only be sent once after the installation finishes." ) ); + ui->machineExplanation->setText( + tr( "By selecting this you will periodically send information about your machine installation, " + "hardware and applications, to %1." ) + .arg( product ) ); + ui->userExplanation->setText( + tr( "By selecting this you will regularly send information about your " + "user installation, hardware, applications and application usage patterns, to %1." ) + .arg( product ) ); +} + +bool +TrackingPage::anyOtherChecked() const +{ + return ui->installCheckBox->isChecked() || ui->machineCheckBox->isChecked() || ui->userCheckBox->isChecked(); +} + + +void +TrackingPage::buttonNoneChecked( int state ) +{ + if ( state ) + { + cDebug() << "Unchecking all other buttons because 'None' was checked"; + ui->installCheckBox->setChecked( false ); + ui->machineCheckBox->setChecked( false ); + ui->userCheckBox->setChecked( false ); + ui->noneCheckBox->setEnabled( false ); + } +} + +void +TrackingPage::buttonChecked( int state ) +{ + if ( state ) + { + // Can't have none checked, if another one is + ui->noneCheckBox->setEnabled( true ); + ui->noneCheckBox->setChecked( false ); + } + else + { + if ( !anyOtherChecked() ) + { + ui->noneCheckBox->setChecked( true ); + ui->noneCheckBox->setEnabled( false ); + } + } +} + +void +TrackingPage::trackerChanged( TrackingStyleConfig* config, QWidget* panel, QCheckBox* check ) +{ + panel->setVisible( config->isConfigurable() ); + check->setChecked( config->isEnabled() ); +} diff --git a/calamares/src/modules/tracking/TrackingPage.h b/calamares/src/modules/tracking/TrackingPage.h new file mode 100644 index 0000000..d9c5a3e --- /dev/null +++ b/calamares/src/modules/tracking/TrackingPage.h @@ -0,0 +1,69 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TRACKINGPAGE_H +#define TRACKINGPAGE_H + +#include "TrackingType.h" + +#include +#include +#include + +namespace Ui +{ +class TrackingPage; +} // namespace Ui + +class Config; +class TrackingStyleConfig; + +class TrackingPage : public QWidget +{ + Q_OBJECT +public: + explicit TrackingPage( Config* config, QWidget* parent = nullptr ); + + /** @brief is any of the enable-tracking buttons checked? + * + * Returns true if any one or more of install, machine or user + * tracking is enabled. + */ + bool anyOtherChecked() const; + +public Q_SLOTS: + void retranslate(); + + /** @brief When the *no tracking* checkbox is changed + * + * @p state will be non-zero when the box is checked; this + * **unchecks** all the other boxes. + */ + void buttonNoneChecked( int state ); + + /** @brief Some other checkbox changed + * + * This may check the *none* button if all the others are + * now unchecked. + */ + void buttonChecked( int state ); + +private: + /** @brief Apply the tracking configuration to the UI + * + * If the config cannot be changed (disabled in config) then + * hide the UI parts on the @p panel; otherwise show it + * and set @p check state to whether the user has enabled it. + */ + void trackerChanged( TrackingStyleConfig* subconfig, QWidget* panel, QCheckBox* check ); + + Ui::TrackingPage* ui; +}; + +#endif //TRACKINGPAGE_H diff --git a/calamares/src/modules/tracking/TrackingType.h b/calamares/src/modules/tracking/TrackingType.h new file mode 100644 index 0000000..81af346 --- /dev/null +++ b/calamares/src/modules/tracking/TrackingType.h @@ -0,0 +1,26 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TRACKINGTYPE_H +#define TRACKINGTYPE_H + +#include "utils/NamedEnum.h" + +enum class TrackingType +{ + NoTracking, // Do not enable tracking at all + InstallTracking, // Track that *this* install has happened + MachineTracking, // Track the machine, ongoing + UserTracking // Track the user, ongoing +}; + +// Implemented in Config.cpp +const NamedEnumTable< TrackingType >& trackingNames(); + +#endif //TRACKINGTYPE_H diff --git a/calamares/src/modules/tracking/TrackingViewStep.cpp b/calamares/src/modules/tracking/TrackingViewStep.cpp new file mode 100644 index 0000000..37f2912 --- /dev/null +++ b/calamares/src/modules/tracking/TrackingViewStep.cpp @@ -0,0 +1,115 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "TrackingViewStep.h" + +#include "Config.h" +#include "TrackingJobs.h" +#include "TrackingPage.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include +#include + +CALAMARES_PLUGIN_FACTORY_DEFINITION( TrackingViewStepFactory, registerPlugin< TrackingViewStep >(); ) + +TrackingViewStep::TrackingViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_config( new Config( this ) ) + , m_widget( new TrackingPage( m_config ) ) +{ + emit nextStatusChanged( false ); +} + + +TrackingViewStep::~TrackingViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +TrackingViewStep::prettyName() const +{ + return tr( "Feedback", "@title" ); +} + + +QWidget* +TrackingViewStep::widget() +{ + return m_widget; +} + + +bool +TrackingViewStep::isNextEnabled() const +{ + return true; +} + + +bool +TrackingViewStep::isBackEnabled() const +{ + return true; +} + + +bool +TrackingViewStep::isAtBeginning() const +{ + return true; +} + + +bool +TrackingViewStep::isAtEnd() const +{ + return true; +} + + +void +TrackingViewStep::onLeave() +{ + cDebug() << "Install tracking:" << m_config->installTracking()->isEnabled(); + cDebug() << Logger::SubEntry << "Machine tracking:" << m_config->machineTracking()->isEnabled(); + cDebug() << Logger::SubEntry << " User tracking:" << m_config->userTracking()->isEnabled(); +} + + +Calamares::JobList +TrackingViewStep::jobs() const +{ + cDebug() << "Creating tracking jobs .."; + + Calamares::JobList l; + addJob( l, m_config->installTracking() ); + addJob( l, m_config->machineTracking() ); + addJob( l, m_config->userTracking() ); + cDebug() << Logger::SubEntry << l.count() << "jobs queued."; + return l; +} + + +void +TrackingViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); +} diff --git a/calamares/src/modules/tracking/TrackingViewStep.h b/calamares/src/modules/tracking/TrackingViewStep.h new file mode 100644 index 0000000..0601dde --- /dev/null +++ b/calamares/src/modules/tracking/TrackingViewStep.h @@ -0,0 +1,57 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef TRACKINGVIEWSTEP_H +#define TRACKINGVIEWSTEP_H + +#include "TrackingType.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include +#include +#include + +class Config; +class TrackingPage; + +class PLUGINDLLEXPORT TrackingViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit TrackingViewStep( QObject* parent = nullptr ); + ~TrackingViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + void onLeave() override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + Config* m_config; + TrackingPage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( TrackingViewStepFactory ) + +#endif // TRACKINGVIEWSTEP_H diff --git a/calamares/src/modules/tracking/level-install.svg b/calamares/src/modules/tracking/level-install.svg new file mode 100644 index 0000000..ffa62ce --- /dev/null +++ b/calamares/src/modules/tracking/level-install.svg @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + ! + + + + + diff --git a/calamares/src/modules/tracking/level-install.svg.license b/calamares/src/modules/tracking/level-install.svg.license new file mode 100644 index 0000000..ef0e9d7 --- /dev/null +++ b/calamares/src/modules/tracking/level-install.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2014 Uri Herrera and others +SPDX-License-Identifier: LGPL-3.0-or-later diff --git a/calamares/src/modules/tracking/level-machine.svg b/calamares/src/modules/tracking/level-machine.svg new file mode 100644 index 0000000..f9df4d5 --- /dev/null +++ b/calamares/src/modules/tracking/level-machine.svg @@ -0,0 +1,271 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/tracking/level-machine.svg.license b/calamares/src/modules/tracking/level-machine.svg.license new file mode 100644 index 0000000..ef0e9d7 --- /dev/null +++ b/calamares/src/modules/tracking/level-machine.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2014 Uri Herrera and others +SPDX-License-Identifier: LGPL-3.0-or-later diff --git a/calamares/src/modules/tracking/level-none.svg b/calamares/src/modules/tracking/level-none.svg new file mode 100644 index 0000000..9cf85ff --- /dev/null +++ b/calamares/src/modules/tracking/level-none.svg @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/tracking/level-none.svg.license b/calamares/src/modules/tracking/level-none.svg.license new file mode 100644 index 0000000..ef0e9d7 --- /dev/null +++ b/calamares/src/modules/tracking/level-none.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2014 Uri Herrera and others +SPDX-License-Identifier: LGPL-3.0-or-later diff --git a/calamares/src/modules/tracking/level-user.svg b/calamares/src/modules/tracking/level-user.svg new file mode 100644 index 0000000..5d9f03a --- /dev/null +++ b/calamares/src/modules/tracking/level-user.svg @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/calamares/src/modules/tracking/level-user.svg.license b/calamares/src/modules/tracking/level-user.svg.license new file mode 100644 index 0000000..ef0e9d7 --- /dev/null +++ b/calamares/src/modules/tracking/level-user.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2014 Uri Herrera and others +SPDX-License-Identifier: LGPL-3.0-or-later diff --git a/calamares/src/modules/tracking/page_trackingstep.qrc b/calamares/src/modules/tracking/page_trackingstep.qrc new file mode 100644 index 0000000..e0147a8 --- /dev/null +++ b/calamares/src/modules/tracking/page_trackingstep.qrc @@ -0,0 +1,9 @@ + + + level-none.svg + level-install.svg + level-machine.svg + level-user.svg + ../../../data/images/information.svgz + + diff --git a/calamares/src/modules/tracking/page_trackingstep.ui b/calamares/src/modules/tracking/page_trackingstep.ui new file mode 100644 index 0000000..291a3a3 --- /dev/null +++ b/calamares/src/modules/tracking/page_trackingstep.ui @@ -0,0 +1,310 @@ + + + +SPDX-FileCopyrightText: 2017 Adriaan de Groot <groot@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + TrackingPage + + + + 0 + 0 + 799 + 400 + + + + Form + + + + + + margin-bottom: 1ex; +margin-left: 2em; + + + Placeholder + + + true + + + + + + + + + + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + + + + :/tracking/level-none.svg + + + + + + + + 0 + 0 + + + + <html><head/><body><p>Click here to send <span style=" font-weight:600;">no information at all</span> about your installation.</p></body></html> + + + true + + + + + + + + + + + + + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + + + + :/tracking/level-install.svg + + + + + + + + 0 + 0 + + + + TextLabel + + + true + + + + + + + ... + + + + :/tracking/data/images/information.svgz:/tracking/data/images/information.svgz + + + + + + + + + + + + + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + + + + :/tracking/level-machine.svg + + + + + + + + 0 + 0 + + + + TextLabel + + + true + + + + + + + ... + + + + :/tracking/data/images/information.svgz:/tracking/data/images/information.svgz + + + + + + + + + + + + + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + + + + :/tracking/level-user.svg + + + + + + + + 0 + 0 + + + + TextLabel + + + true + + + + + + + ... + + + + :/tracking/data/images/information.svgz:/tracking/data/images/information.svgz + + + + + + + + + + <html><head/><body><p><a href="placeholder"><span style=" text-decoration: underline; color:#2980b9;">Click here for more information about user feedback</span></a></p></body></html> + + + Qt::RichText + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + false + + + Qt::TextBrowserInteraction + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + diff --git a/calamares/src/modules/tracking/tracking.conf b/calamares/src/modules/tracking/tracking.conf new file mode 100644 index 0000000..fdbd355 --- /dev/null +++ b/calamares/src/modules/tracking/tracking.conf @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Settings for various kinds of tracking that Distributions can +# enable. Distributions looking at tracking should be aware of +# the privacy (and hence communications) impact of that tracking, +# and are advised to consult the Mozilla and KDE policies on +# privacy and user tracking. +# +# There are three areas of tracking (-configuration) supported +# by Calamares It is up to individual Distributions to create +# suitable backends / configuration scripts for each. The +# different areas are: +# +# install: This is "phone home" functionality at the end of the +# install process. When enabled, it contacts the given +# URL. The URL can contain the special token $MACHINE, +# which is replaced by the machine-id of the installed +# system (if available, blank otherwise). +# +# machine: This enables machine-level tracking on a (semi-) +# continuous basis. It is meant to keep track of installed +# systems and their continued use / updating. +# +# user: This area enables user-level tracking, along the lines +# of the KDE User Telemetry Policy. It enables specific +# collection of data at a user- and application-level, +# possibly including actions done in an application. +# For the KDE environment, this enables user tracking +# with the appropriate framework, and the KDE User Telemetry +# policy applies. +# +# Each area has a key *enabled*. If the area is enabled, it is shown to +# the user. This defaults to false, which means no tracking would be +# configured or enabled by Calamares. +# +# Each area has a key *policy*, which is a Url to be opened when +# the user clicks on the corresponding Help button for an explanation +# of the details of that particular kind of tracking. If no policy +# is set, that tracking style is disabled. The example policy links +# go to Calamares' generic user manual (which is a terrible idea +# for a distribution: you have GDPR obligations under most of these +# tracking styles, so do your homework). +# +# Each area may have other configuration keys, depending on the +# area and how it needs to be configured. +# +# Globally, there are two other keys: +# +# policy: (optional) url about tracking settings for this distro. +# default: (optional) level to enable by default +# +--- +# This is the global policy; it is displayed as a link on the page. +# If blank or commented out, no link is displayed on the tracking +# page. You **must** provide policy links per-area as well. +policy: "https://calamares.io/docs/tracking#policy" + +# This is the default area to enable for tracking. If commented out, +# empty, or otherwise invalid, "none" is used, so no tracking by default. +# Setting an area here also checks the areas before it (install, machine, +# then user) by default -- subject to those areas being enabled at all. +# default: user + +# The install area has one specific configuration key: +# url: this URL (remember to include the protocol, and prefer https) +# is fetched (with a GET request, and the data discarded) at +# the end of the installation process. The following tokens +# are replaced in the url (possibly by blank strings, or by 0). +# - $CPU (cpu make and model) +# - $MEMORY (amount of main memory available) +# - $DISK (total amount of disk attached) +# Typically these are used as GET parameters, as in the example. +# +# Note that phone-home only works if the system has an internet +# connection; it is a good idea to require internet in the welcome +# module then. +install: + enabled: false + policy: "https://calamares.io/docs/tracking#policy" + url: "https://example.com/install.php?c=$CPU&m=$MEMORY" + +# The machine area has one specific configuration key: +# style: This string specifies what kind of tracking configuration +# needs to be done. See below for valid styles. +# +# Available styles: +# - *updatemanager* replaces the literal string "${MACHINE_ID}" with the contents of +# /etc/machine-id, in lines starting with "URI" in the file /etc/update-manager/meta-release +machine: + enabled: false + style: updatemanager + policy: "https://calamares.io/docs/tracking#policy" + +# The user area has one specific configuration key: +# style: This string specifies what kind of tracking configuration +# needs to be done. See below for valid styles. +# +# Available styles: +# - *kuserfeedback* sets up KUserFeedback tracking (applicable to the KDE +# Plasma Desktop) for each KUserFeedback area listed in *areas*. +user: + enabled: false + style: kuserfeedback + areas: [ PlasmaUserFeedback ] diff --git a/calamares/src/modules/umount/CMakeLists.txt b/calamares/src/modules/umount/CMakeLists.txt new file mode 100644 index 0000000..09e6a08 --- /dev/null +++ b/calamares/src/modules/umount/CMakeLists.txt @@ -0,0 +1,15 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2021 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(umount + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + UmountJob.cpp + SHARED_LIB + EMERGENCY +) + +calamares_add_test(umounttest SOURCES Tests.cpp) diff --git a/calamares/src/modules/umount/Tests.cpp b/calamares/src/modules/umount/Tests.cpp new file mode 100644 index 0000000..a0ae8bc --- /dev/null +++ b/calamares/src/modules/umount/Tests.cpp @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "UmountJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include +#include +#include + +// Internals of UmountJob.cpp + +// Actual tests +class UmountTests : public QObject +{ + Q_OBJECT +public: + UmountTests() {} + ~UmountTests() override {} + +private Q_SLOTS: + void initTestCase(); + void testTrue(); +}; + +void +UmountTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); +} + +void +UmountTests::testTrue() +{ + QVERIFY( true ); +} + +QTEST_GUILESS_MAIN( UmountTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/umount/UmountJob.cpp b/calamares/src/modules/umount/UmountJob.cpp new file mode 100644 index 0000000..f899d8e --- /dev/null +++ b/calamares/src/modules/umount/UmountJob.cpp @@ -0,0 +1,173 @@ +/* === This file is part of Calamares - === + * + * Tags from the Python version of this module: + * SPDX-FileCopyrightText: 2014 Aurélien Gâteau + * SPDX-FileCopyrightText: 2016 Anke Boersma + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * Tags for the C++ version of this module: + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "UmountJob.h" + +#include "partition/Mount.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" + +#include +#include +#include + +UmountJob::UmountJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +UmountJob::~UmountJob() {} + +QString +UmountJob::prettyName() const +{ + return tr( "Unmounting file systems…", "@status" ); +} + +static Calamares::JobResult +unmountTargetMounts( const QString& rootMountPoint ) +{ + QDir targetMount( rootMountPoint ); + if ( !targetMount.exists() ) + { + return Calamares::JobResult::internalError( + QCoreApplication::translate( UmountJob::staticMetaObject.className(), "Could not unmount target system." ), + QCoreApplication::translate( UmountJob::staticMetaObject.className(), + "The target system is not mounted at '%1'." ) + .arg( rootMountPoint ), + Calamares::JobResult::GenericError ); + } + QString targetMountPath = targetMount.absolutePath(); + if ( !targetMountPath.endsWith( '/' ) ) + { + targetMountPath.append( '/' ); + } + + using MtabInfo = Calamares::Partition::MtabInfo; + auto targetMounts = MtabInfo::fromMtabFilteredByPrefix( targetMountPath ); + std::sort( targetMounts.begin(), targetMounts.end(), MtabInfo::mountPointOrder ); + + cDebug() << "Read" << targetMounts.count() << "entries from" << targetMountPath; + for ( const auto& m : std::as_const( targetMounts ) ) + { + // Returns the program's exit code, so 0 is success and non-0 + // (truthy) is a failure. + if ( Calamares::Partition::unmount( m.mountPoint, { "-lv" } ) ) + { + return Calamares::JobResult::error( + QCoreApplication::translate( UmountJob::staticMetaObject.className(), + "Could not unmount target system." ), + QCoreApplication::translate( UmountJob::staticMetaObject.className(), + "The device '%1' is mounted in the target system. It is mounted at '%2'. " + "The device could not be unmounted." ) + .arg( m.device, m.mountPoint ) ); + } + } + + // Last we unmount the root + if ( Calamares::Partition::unmount( rootMountPoint, { "-lv" } ) ) + { + return Calamares::JobResult::error( + QCoreApplication::translate( UmountJob::staticMetaObject.className(), + "Could not unmount the root of the target system." ), + QCoreApplication::translate( UmountJob::staticMetaObject.className(), + "The device mounted at '%1' could not be unmounted." ) + .arg( rootMountPoint ) ); + } + + return Calamares::JobResult::ok(); +} + +static Calamares::JobResult +exportZFSPools() +{ + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + QStringList poolNames; + { + // The pools are dictionaries / VariantMaps + auto zfs_pool_list = gs->value( "zfsPoolInfo" ).toList(); + for ( const auto& v : zfs_pool_list ) + { + auto m = v.toMap(); + QString poolName = m.value( "poolName" ).toString(); + if ( !poolName.isEmpty() ) + { + poolNames.append( poolName ); + } + } + poolNames.sort(); + } + + for ( const auto& poolName : poolNames ) + { + auto result = Calamares::System::runCommand( { "zpool", "export", poolName }, std::chrono::seconds( 30 ) ); + if ( result.getExitCode() ) + { + cWarning() << "Failed to export pool" << result.getOutput(); + } + } + // Exporting ZFS pools does not cause the install to fail + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +UmountJob::exec() +{ + const auto* sys = Calamares::System::instance(); + if ( !sys ) + { + return Calamares::JobResult::internalError( + "UMount", tr( "No target system available." ), Calamares::JobResult::InvalidConfiguration ); + } + + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + if ( !gs || gs->value( "rootMountPoint" ).toString().isEmpty() ) + { + return Calamares::JobResult::internalError( + "UMount", tr( "No rootMountPoint is set." ), Calamares::JobResult::InvalidConfiguration ); + } + + // Do the unmounting of target-system filesystems + { + auto r = unmountTargetMounts( gs->value( "rootMountPoint" ).toString() ); + if ( !r ) + { + return r; + } + } + + // For ZFS systems, export the pools + { + auto r = exportZFSPools(); + if ( !r ) + { + return r; + } + } + + return Calamares::JobResult::ok(); +} + +void +UmountJob::setConfigurationMap( const QVariantMap& map ) +{ + Q_UNUSED( map ) +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( UmountJobFactory, registerPlugin< UmountJob >(); ) diff --git a/calamares/src/modules/umount/UmountJob.h b/calamares/src/modules/umount/UmountJob.h new file mode 100644 index 0000000..6ca5428 --- /dev/null +++ b/calamares/src/modules/umount/UmountJob.h @@ -0,0 +1,41 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UMOUNTJOB_H +#define UMOUNTJOB_H + +#include "CppJob.h" +#include "DllMacro.h" +#include "utils/PluginFactory.h" + +#include +#include +#include + +/** @brief Write 'random' data: machine id, entropy, UUIDs + * + */ +class PLUGINDLLEXPORT UmountJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit UmountJob( QObject* parent = nullptr ); + ~UmountJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( UmountJobFactory ) + +#endif // UMOUNTJOB_H diff --git a/calamares/src/modules/umount/umount.conf b/calamares/src/modules/umount/umount.conf new file mode 100644 index 0000000..5842c87 --- /dev/null +++ b/calamares/src/modules/umount/umount.conf @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +### Umount Module +# +# This module represents the last part of the installation, the unmounting +# of partitions used for the install. After this, there is no regular way +# to modify the target system anymore. +# + +--- +# Setting emergency to true will make it so this module is still run +# when a prior module fails +emergency: true diff --git a/calamares/src/modules/umount/umount.schema.yaml b/calamares/src/modules/umount/umount.schema.yaml new file mode 100644 index 0000000..37771e5 --- /dev/null +++ b/calamares/src/modules/umount/umount.schema.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/umount +additionalProperties: false +type: object +properties: + emergency: { type: boolean } diff --git a/calamares/src/modules/unpackfs/main.py b/calamares/src/modules/unpackfs/main.py new file mode 100644 index 0000000..fdb4fc9 --- /dev/null +++ b/calamares/src/modules/unpackfs/main.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2014 Teo Mrnjavac +# SPDX-FileCopyrightText: 2014 Daniel Hillenbrand +# SPDX-FileCopyrightText: 2014 Philip Müller +# SPDX-FileCopyrightText: 2017 Alf Gaida +# SPDX-FileCopyrightText: 2019 Kevin Kofler +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-FileCopyrightText: 2020 Gabriel Craciunescu +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import os +import re +import shutil +import subprocess +import sys +import tempfile + +import libcalamares + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + +def pretty_name(): + return _("Filling up filesystems.") + +# This is going to be changed from various methods +status = pretty_name() + +def pretty_status_message(): + return status + +class UnpackEntry: + """ + Extraction routine using rsync. + + :param source: + :param sourcefs: + :param destination: + """ + __slots__ = ('source', 'sourcefs', 'destination', 'copied', 'total', 'exclude', 'excludeFile', + 'mountPoint', 'weight', 'condition', 'optional') + + def __init__(self, source, sourcefs, destination): + """ + @p source is the source file name (might be an image file, or + a directory, too) + @p sourcefs is a type indication; "file" is special, as is + "squashfs". + @p destination is where the files from the source go. This is + **already** prefixed by rootMountPoint, so should be a + valid absolute path within the host system. + + The members copied and total are filled in by the copying process. + """ + self.source = source + self.sourcefs = sourcefs + self.destination = destination + self.exclude = None + self.excludeFile = None + self.copied = 0 + self.total = 0 + self.mountPoint = None + self.weight = 1 + self.condition = True + self.optional = False + + def is_file(self): + return self.sourcefs == "file" + + def do_count(self): + """ + Counts the number of files this entry has. + """ + # Need a name we can use like a global + class counter(object): + count = 0 + def cb_count(s): + counter.count += 1 + + if self.sourcefs == "squashfs": + libcalamares.utils.host_env_process_output(["unsquashfs", "-l", self.source], cb_count) + + elif self.sourcefs == "ext4": + libcalamares.utils.host_env_process_output(["find", self.mountPoint, "-type", "f"], cb_count) + + elif self.is_file(): + # Hasn't been mounted, copy directly; find handles both + # files and directories. + libcalamares.utils.host_env_process_output(["find", self.source, "-type", "f"], cb_count) + + self.total = counter.count + return self.total + + def do_mount(self, base): + """ + Mount given @p entry as loop device underneath @p base + + A *file* entry (e.g. one with *sourcefs* set to *file*) + is not mounted and just ignored. + + :param base: directory to place all the mounts in. + + :returns: None, but throws if the mount failed + """ + imgbasename = os.path.splitext( + os.path.basename(self.source))[0] + imgmountdir = os.path.join(base, imgbasename) + os.makedirs(imgmountdir, exist_ok=True) + + # This is where it *would* go (files bail out before actually mounting) + self.mountPoint = imgmountdir + + if self.is_file(): + return + + if os.path.isdir(self.source): + r = libcalamares.utils.mount(self.source, imgmountdir, "", "--bind") + elif os.path.isfile(self.source): + r = libcalamares.utils.mount(self.source, imgmountdir, self.sourcefs, "loop") + else: # self.source is a device + r = libcalamares.utils.mount(self.source, imgmountdir, self.sourcefs, "") + + if r != 0: + libcalamares.utils.debug("Failed to mount '{}' (fs={}) (target={})".format(self.source, self.sourcefs, imgmountdir)) + raise subprocess.CalledProcessError(r, "mount") + + +ON_POSIX = 'posix' in sys.builtin_module_names + + +def global_excludes(): + """ + List excludes for rsync. + """ + lst = [] + extra_mounts = libcalamares.globalstorage.value("extraMounts") + if extra_mounts is None: + extra_mounts = [] + + for extra_mount in extra_mounts: + mount_point = extra_mount["mountPoint"] + + if mount_point: + lst.extend(['--exclude', mount_point + '/']) + + return lst + +def file_copy(source, entry, progress_cb): + """ + Extract given image using rsync. + + :param source: Source file. This may be the place the entry's + image is mounted, or if it's a single file, the entry's source value. + :param entry: The UnpackEntry being copied. + :param progress_cb: A callback function for progress reporting. + Takes a number and a total-number. + """ + import time + + dest = entry.destination + + # `source` *must* end with '/' otherwise a directory named after the source + # will be created in `dest`: ie if `source` is "/foo/bar" and `dest` is + # "/dest", then files will be copied in "/dest/bar". + if not source.endswith("/") and not os.path.isfile(source): + source += "/" + + num_files_total_local = 0 + num_files_copied = 0 # Gets updated through rsync output + + args = ['rsync', '-aHAXSr', '--filter=-x trusted.overlay.*'] + args.extend(global_excludes()) + if entry.excludeFile: + args.extend(["--exclude-from=" + entry.excludeFile]) + if entry.exclude: + for f in entry.exclude: + args.extend(["--exclude", f]) + args.extend(['--progress', source, dest]) + + # last_num_files_copied trails num_files_copied, and whenever at least 107 more + # files (file_count_chunk) have been copied, progress is reported and + # last_num_files_copied is updated. The chunk size isn't "tidy" + # so that all the digits of the progress-reported number change. + # + file_count_chunk = 107 + + class counter(object): + last_num_files_copied = 0 + last_timestamp_reported = time.time() + last_total_reported = 0 + + def output_cb(line): + # rsync outputs progress in parentheses. Each line will have an + # xfer and a chk item (either ir-chk or to-chk) as follows: + # + # - xfer#x => Interpret it as 'file copy try no. x' + # - ir-chk=x/y, where: + # - x = number of files yet to be checked + # - y = currently calculated total number of files. + # - to-chk=x/y, which is similar and happens once the ir-chk + # phase (collecting total files) is over. + # + # If you're copying directory with some links in it, the xfer# + # might not be a reliable counter (for one increase of xfer, many + # files may be created). + m = re.findall(r'xfr#(\d+), ..-chk=(\d+)/(\d+)', line) + + if m: + # we've got a percentage update + num_files_remaining = int(m[0][1]) + num_files_total_local = int(m[0][2]) + # adjusting the offset so that progressbar can be continuesly drawn + num_files_copied = num_files_total_local - num_files_remaining + + now = time.time() + if (num_files_copied - counter.last_num_files_copied >= file_count_chunk) or (now - counter.last_timestamp_reported > 0.5): + counter.last_num_files_copied = num_files_copied + counter.last_timestamp_reported = now + counter.last_total_reported = num_files_total_local + progress_cb(num_files_copied, num_files_total_local) + + try: + returncode = libcalamares.utils.host_env_process_output(args, output_cb) + except subprocess.CalledProcessError as e: + returncode = e.returncode + + progress_cb(counter.last_num_files_copied, counter.last_total_reported) # Push towards 100% + + # Mark this entry as really done + entry.copied = entry.total + + # 23 is the return code rsync returns if it cannot write extended + # attributes (with -X) because the target file system does not support it, + # e.g., the FAT EFI system partition. We need -X because distributions + # using file system capabilities and/or SELinux require the extended + # attributes. But distributions using SELinux may also have SELinux labels + # set on files under /boot/efi, and rsync complains about those. The only + # clean way would be to split the rsync into one with -X and + # --exclude /boot/efi and a separate one without -X for /boot/efi, but only + # if /boot/efi is actually an EFI system partition. For now, this hack will + # have to do. See also: + # https://bugzilla.redhat.com/show_bug.cgi?id=868755#c50 + # for the same issue in Anaconda, which uses a similar workaround. + if returncode != 0 and returncode != 23: + libcalamares.utils.warning("rsync failed with error code {}.".format(returncode)) + return _("rsync failed with error code {}.").format(returncode) + + return None + + +class UnpackOperation: + """ + Extraction routine using unsquashfs. + + :param entries: + """ + + def __init__(self, entries): + self.entries = entries + self.entry_for_source = dict((x.source, x) for x in self.entries) + self.total_weight = sum([e.weight for e in entries]) + + def report_progress(self): + """ + Pass progress to user interface + """ + progress = float(0) + + current_total = 0 + current_done = 0 # Files count in the current entry + complete_count = 0 + complete_weight = 0 # This much weight already finished + for entry in self.entries: + if entry.total == 0: + # Total 0 hasn't counted yet + continue + if entry.total == entry.copied: + complete_weight += entry.weight + complete_count += 1 + else: + # There is at most *one* entry in-progress + current_total = entry.total + current_done = entry.copied + complete_weight += entry.weight * ( 1.0 * current_done ) / current_total + break + + if current_total > 0: + progress = ( 1.0 * complete_weight ) / self.total_weight + + global status + status = _("Unpacking image {}/{}, file {}/{}").format((complete_count+1), len(self.entries), current_done, current_total) + libcalamares.job.setprogress(progress) + + def run(self): + """ + Extract given image using unsquashfs. + + :return: + """ + global status + source_mount_path = tempfile.mkdtemp() + + try: + complete = 0 + for entry in self.entries: + status = _("Starting to unpack {}").format(entry.source) + libcalamares.job.setprogress( ( 1.0 * complete ) / len(self.entries) ) + entry.do_mount(source_mount_path) + entry.do_count() # Fill in the entry.total + + self.report_progress() + error_msg = self.unpack_image(entry, entry.mountPoint) + + if error_msg: + return (_("Failed to unpack image \"{}\"").format(entry.source), + error_msg) + complete += 1 + + return None + finally: + shutil.rmtree(source_mount_path, ignore_errors=True, onerror=None) + + + def unpack_image(self, entry, imgmountdir): + """ + Unpacks image. + + :param entry: + :param imgmountdir: + :return: + """ + def progress_cb(copied, total): + """ Copies file to given destination target. + + :param copied: + """ + entry.copied = copied + if total > entry.total: + entry.total = total + self.report_progress() + + try: + if entry.is_file(): + source = entry.source + else: + source = imgmountdir + + return file_copy(source, entry, progress_cb) + finally: + if not entry.is_file(): + subprocess.check_call(["umount", "-l", imgmountdir]) + + +def get_supported_filesystems_kernel(): + """ + Reads /proc/filesystems (the list of supported filesystems + for the current kernel) and returns a list of (names of) + those filesystems. + """ + PATH_PROCFS = '/proc/filesystems' + + if os.path.isfile(PATH_PROCFS) and os.access(PATH_PROCFS, os.R_OK): + with open(PATH_PROCFS, 'r') as procfile: + filesystems = procfile.read() + filesystems = filesystems.replace( + "nodev", "").replace("\t", "").splitlines() + return filesystems + + return [] + + +def get_supported_filesystems(): + """ + Returns a list of all the supported filesystems + (valid values for the *sourcefs* key in an item. + """ + return ["file"] + get_supported_filesystems_kernel() + + +def repair_root_permissions(root_mount_point): + """ + If the / of the system gets permission 777, change it down + to 755. Any other permission is left alone. This + works around standard behavior from squashfs where + permissions are (easily, accidentally) set to 777. + """ + existing_root_mode = os.stat(root_mount_point).st_mode & 0o777 + if existing_root_mode == 0o777: + try: + os.chmod(root_mount_point, 0o755) # Want / to be rwxr-xr-x + except OSError as e: + libcalamares.utils.warning("Could not set / to safe permissions: {}".format(e)) + # But ignore it + + +def extract_weight(entry): + """ + Given @p entry, a dict representing a single entry in + the *unpack* list, returns its weight (1, or whatever is + set if it is sensible). + """ + w = entry.get("weight", None) + if w: + try: + wi = int(w) + return wi if wi > 0 else 1 + except ValueError: + libcalamares.utils.warning("*weight* setting {!r} is not valid.".format(w)) + except TypeError: + libcalamares.utils.warning("*weight* setting {!r} must be number.".format(w)) + return 1 + + +def fetch_from_globalstorage(keys_list): + value = libcalamares.globalstorage.value(keys_list[0]) + if value is None: + return None + for key in keys_list[1:]: + if isinstance(value, dict) and key in value: + value = value[key] + else: + return None + return value + + +def run(): + """ + Unsquash filesystem. + """ + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + + if not root_mount_point: + libcalamares.utils.warning("No mount point for root partition") + return (_("No mount point for root partition"), + _("globalstorage does not contain a \"rootMountPoint\" key.")) + if not os.path.exists(root_mount_point): + libcalamares.utils.warning("Bad root mount point \"{}\"".format(root_mount_point)) + return (_("Bad mount point for root partition"), + _("rootMountPoint is \"{}\", which does not exist.".format(root_mount_point))) + + if libcalamares.job.configuration.get("unpack", None) is None: + libcalamares.utils.warning("No *unpack* key in job configuration.") + return (_("Bad unpackfs configuration"), + _("There is no configuration information.")) + + supported_filesystems = get_supported_filesystems() + + # Bail out before we start when there are obvious problems + # - unsupported filesystems + # - non-existent sources + # - missing tools for specific FS + for entry in libcalamares.job.configuration["unpack"]: + source = os.path.abspath(entry["source"]) + sourcefs = entry["sourcefs"] + optional = entry.get("optional", False) + + if sourcefs not in supported_filesystems: + libcalamares.utils.warning("The filesystem for \"{}\" ({}) is not supported by your current kernel".format(source, sourcefs)) + libcalamares.utils.warning(" ... modprobe {} may solve the problem".format(sourcefs)) + return (_("Bad unpackfs configuration"), + _("The filesystem for \"{}\" ({}) is not supported by your current kernel").format(source, sourcefs)) + if not os.path.exists(source): + if optional: + libcalamares.utils.warning("The source filesystem \"{}\" does not exist but is marked as optional, skipping".format(source)) + entry["condition"] = False + continue + else: + libcalamares.utils.warning("The source filesystem \"{}\" does not exist".format(source)) + return (_("Bad unpackfs configuration"), + _("The source filesystem \"{}\" does not exist").format(source)) + if sourcefs == "squashfs": + if shutil.which("unsquashfs") is None: + libcalamares.utils.warning("Failed to find unsquashfs") + + return (_("Bad unpackfs configuration"), + _("Failed to find unsquashfs, make sure you have the squashfs-tools package installed.") + + " " + _("Failed to unpack image \"{}\"").format(source)) + + unpack = list() + + is_first = True + for entry in libcalamares.job.configuration["unpack"]: + source = os.path.abspath(entry["source"]) + sourcefs = entry["sourcefs"] + destination = os.path.abspath(root_mount_point + entry["destination"]) + + condition = entry.get("condition", True) + if isinstance(condition, bool): + pass # 'condition' is already True or False + elif isinstance(condition, str): + keys = condition.split(".") + gs_value = fetch_from_globalstorage(keys) + if gs_value is None: + libcalamares.utils.warning("Condition key '{}' not found in global storage, assuming False".format(condition)) + condition = False + elif isinstance(gs_value, bool): + condition = gs_value + else: + libcalamares.utils.warning("Condition key '{}' is not a boolean, assuming True".format(condition)) + condition = True + else: + libcalamares.utils.warning("Invalid 'condition' value '{}', assuming True".format(condition)) + condition = True + + if not condition: + libcalamares.utils.debug("Skipping unpack of {} due to 'condition' being False".format(source)) + continue + + if not os.path.isdir(destination) and sourcefs != "file": + libcalamares.utils.warning(("The destination \"{}\" in the target system is not a directory").format(destination)) + if is_first: + return (_("Bad unpackfs configuration"), + _("The destination \"{}\" in the target system is not a directory").format(destination)) + else: + libcalamares.utils.debug(".. assuming that the previous targets will create that directory.") + + unpack.append(UnpackEntry(source, sourcefs, destination)) + # Optional settings + if entry.get("exclude", None): + unpack[-1].exclude = entry["exclude"] + if entry.get("excludeFile", None): + unpack[-1].excludeFile = entry["excludeFile"] + unpack[-1].weight = extract_weight(entry) + + is_first = False + + repair_root_permissions(root_mount_point) + try: + unpackop = UnpackOperation(unpack) + return unpackop.run() + finally: + repair_root_permissions(root_mount_point) diff --git a/calamares/src/modules/unpackfs/module.desc b/calamares/src/modules/unpackfs/module.desc new file mode 100644 index 0000000..2723c3c --- /dev/null +++ b/calamares/src/modules/unpackfs/module.desc @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# Syntax is YAML 1.2 +--- +type: "job" +name: "unpackfs" +interface: "python" +script: "main.py" +requiredModules: [ mount ] +weight: 12 diff --git a/calamares/src/modules/unpackfs/runtests.sh b/calamares/src/modules/unpackfs/runtests.sh new file mode 100644 index 0000000..2269b07 --- /dev/null +++ b/calamares/src/modules/unpackfs/runtests.sh @@ -0,0 +1,37 @@ +#! /bin/sh +# +# SPDX-FileCopyrightText: 2019 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +# Test preparation for unpackfs; since there's a bunch +# of fiddly bits than need to be present for the tests, +# do that in a script rather than entirely in CTest. +# +SRCDIR=$( dirname "$0" ) + +# For test 3 +mkdir /tmp/unpackfs-test-run-rootdir3 + +# For test 7 +mkdir /tmp/unpackfs-test-run-rootdir3/realdest + +# For test 9 +mkdir /tmp/unpackfs-test-run-rootdir3/smalldest +if test 0 = $( id -u ) ; then + mount -t tmpfs -o size=32M tmpfs /tmp/unpackfs-test-run-rootdir3/smalldest + dd if=/dev/zero of=/tmp/unpackfs-test-run-rootdir3/smalldest/bogus.zero bs=1M count=1 +fi + +# Run tests +sh "$SRCDIR/../testpythonrun.sh" unpackfs + +# Cleanup test 9 +if test 0 = $( id -u ) ; then + umount /tmp/unpackfs-test-run-rootdir3/smalldest +fi + +# Cleanup test 7 +rm -rf /tmp/unpackfs-test-run-rootdir3/realdest + +# Cleanup test 3 +rmdir /tmp/unpackfs-test-run-rootdir3 diff --git a/calamares/src/modules/unpackfs/tests/1.global b/calamares/src/modules/unpackfs/tests/1.global new file mode 100644 index 0000000..7dedc15 --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/1.global @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +bogus: true diff --git a/calamares/src/modules/unpackfs/tests/2.global b/calamares/src/modules/unpackfs/tests/2.global new file mode 100644 index 0000000..d1e61ca --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/2.global @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir/ diff --git a/calamares/src/modules/unpackfs/tests/3.global b/calamares/src/modules/unpackfs/tests/3.global new file mode 100644 index 0000000..1c25cbe --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/3.global @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir3/ diff --git a/calamares/src/modules/unpackfs/tests/3.job b/calamares/src/modules/unpackfs/tests/3.job new file mode 100644 index 0000000..82d3531 --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/3.job @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +unpack: [] diff --git a/calamares/src/modules/unpackfs/tests/4.global b/calamares/src/modules/unpackfs/tests/4.global new file mode 100644 index 0000000..1c25cbe --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/4.global @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir3/ diff --git a/calamares/src/modules/unpackfs/tests/4.job b/calamares/src/modules/unpackfs/tests/4.job new file mode 100644 index 0000000..e6b067d --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/4.job @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Test that a "bogus" sourcefs (a filesystem kind that does not +# exist) fails gracefully. +--- +unpack: + - source: . + sourcefs: bogus + destination: / diff --git a/calamares/src/modules/unpackfs/tests/5.global b/calamares/src/modules/unpackfs/tests/5.global new file mode 100644 index 0000000..1c25cbe --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/5.global @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir3/ diff --git a/calamares/src/modules/unpackfs/tests/5.job b/calamares/src/modules/unpackfs/tests/5.job new file mode 100644 index 0000000..268ee7c --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/5.job @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +unpack: + - source: ./fakesource + sourcefs: ext4 + destination: fakedest diff --git a/calamares/src/modules/unpackfs/tests/6.global b/calamares/src/modules/unpackfs/tests/6.global new file mode 100644 index 0000000..1c25cbe --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/6.global @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir3/ diff --git a/calamares/src/modules/unpackfs/tests/6.job b/calamares/src/modules/unpackfs/tests/6.job new file mode 100644 index 0000000..1ec0840 --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/6.job @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +unpack: + - source: . + sourcefs: ext4 + destination: fakedest diff --git a/calamares/src/modules/unpackfs/tests/7.global b/calamares/src/modules/unpackfs/tests/7.global new file mode 100644 index 0000000..1c25cbe --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/7.global @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir3/ diff --git a/calamares/src/modules/unpackfs/tests/7.job b/calamares/src/modules/unpackfs/tests/7.job new file mode 100644 index 0000000..ffd898f --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/7.job @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +unpack: + - source: . + sourcefs: ext4 + destination: realdest diff --git a/calamares/src/modules/unpackfs/tests/8.global b/calamares/src/modules/unpackfs/tests/8.global new file mode 100644 index 0000000..15c3085 --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/8.global @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir/ +localeConf: + - LANG: nl diff --git a/calamares/src/modules/unpackfs/tests/8.job b/calamares/src/modules/unpackfs/tests/8.job new file mode 100644 index 0000000..ffd898f --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/8.job @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +unpack: + - source: . + sourcefs: ext4 + destination: realdest diff --git a/calamares/src/modules/unpackfs/tests/9.global b/calamares/src/modules/unpackfs/tests/9.global new file mode 100644 index 0000000..e7a2cd9 --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/9.global @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# This test uses a small destination FS, to make rsync fail +--- +rootMountPoint: /tmp/unpackfs-test-run-rootdir3/ diff --git a/calamares/src/modules/unpackfs/tests/9.job b/calamares/src/modules/unpackfs/tests/9.job new file mode 100644 index 0000000..b896334 --- /dev/null +++ b/calamares/src/modules/unpackfs/tests/9.job @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# This test uses a small destination FS, to make rsync fail +--- +unpack: + - source: . + sourcefs: ext4 + destination: smalldest diff --git a/calamares/src/modules/unpackfs/unpackfs.conf b/calamares/src/modules/unpackfs/unpackfs.conf new file mode 100644 index 0000000..1576fa7 --- /dev/null +++ b/calamares/src/modules/unpackfs/unpackfs.conf @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Unsquash / unpack a filesystem. Multiple sources are supported, and +# they may be squashed or plain filesystems. +# +# Configuration: +# +# from globalstorage: rootMountPoint +# from job.configuration: the path to where to mount the source image(s) +# for copying an ordered list of unpack mappings for image file <-> +# target dir relative to rootMountPoint. + +--- +# Each list item is unpacked, in order, to the target system. +# +# Each list item has the following **mandatory** attributes: +# - *source* path relative to the live / intstalling system to the image +# - *sourcefs* the type of the source files; valid entries are +# - `ext4` (copies the filesystem contents) +# - `squashfs` (unsquashes) +# - `file` (copies a file or directory) +# - (may be others if mount supports it) +# - *destination* path relative to rootMountPoint (so in the target +# system) where this filesystem is unpacked. It may be an +# empty string, which effectively is / (the root) of the target +# system. +# +# Each list item **optionally** can include the following attributes: +# - *exclude* is a list of values that is expanded into --exclude +# arguments for rsync (each entry in exclude gets its own --exclude). +# - *excludeFile* is a single file that is passed to rsync as an +# --exclude-file argument. This should be a full pathname +# inside the **host** filesystem. +# - *weight* is useful when the entries take wildly different +# times to unpack (e.g. with a squashfs, and one single file) +# and the total weight of this module should be distributed +# differently between the entries. (This is only relevant when +# there is more than one entry; by default all the entries +# have the same weight, 1) +# +# EXAMPLES +# +# Usually you list a filesystem image to unpack; you can use +# squashfs or an ext4 image. An empty destination is equivalent to "/", +# the root of the target system. The destination directory must exist +# in the target system. +# +# - source: "/path/to/filesystem.sqfs" +# sourcefs: "squashfs" +# destination: "" +# +# Multiple entries are unpacked in-order; if there is more than one +# item then only the first must exist beforehand -- it's ok to +# create directories with one unsquash and then to use those +# directories as a target from a second unsquash. +# +# - source: "/path/to/another/filesystem.img" +# sourcefs: "ext4" +# destination: "" +# - source: "/path/to/another/filesystem2.img" +# sourcefs: "ext4" +# destination: "/usr/lib/extra" +# +# You can list filesystem source paths relative to the Calamares run +# directory, if you use -d (this is only useful for testing, though). +# +# - source: ./example.sqfs +# sourcefs: squashfs +# destination: "" +# +# You can list individual files (copied one-by-one), or directories +# (the files inside this directory are copied directly to the destination, +# so no "dummycpp/" subdirectory is created in this example). +# Do note that the target directory must exist already (e.g. from +# extracting some other filesystem). +# +# - source: ../CHANGES +# sourcefs: file +# destination: "/tmp/derp" +# - source: ../src/modules/dummycpp +# sourcefs: file +# destination: "/tmp/derp" +# +# The *destination* and *source* are handed off to rsync, so the semantics +# of trailing slashes apply. In order to *rename* a file as it is +# copied, specify one single file (e.g. CHANGES) and a full pathname +# for its destination name, as in the example below. +# +# It is also possible to dynamically (conditionally) unpack a source by passing a boolean +# value for *condition*. This may be true or false (constant) or name a globalstorage +# value. Use '.' to separate parts of a globalstorage name if it is nested. +# +# This is used in e.g. stacked squashfses, where the user can select a specific +# install type. The default value of *condition* is true. +# +# - source: ./example.minimal.sqfs +# sourcefs: squashfs +# destination: "" +# condition: false +# - source: ./example.standard.sqfs +# sourcefs: squashfs +# destination: "" +# condition: exampleGlobalStorageVariable.subkey +# +# You may also wish to include optional squashfses, which may not exist at certain times +# depending on your image tooling. If an optional squashfs is not found, it is simply +# skipped. +# +# - source: ./example.standard.sqfs +# sourcefs: squashfs +# destination: "" +# - source: ./example.extras.sqfs +# sourcefs: squashfs +# destination: "" +# optional: true + +unpack: + - source: ../CHANGES + sourcefs: file + destination: "/tmp/changes.txt" + weight: 1 # Single file + - source: src/qml/calamares/slideshow + sourcefs: file + destination: "/tmp/slideshow/" + exclude: [ "*.qmlc", "qmldir" ] + weight: 5 # Lots of files + # excludeFile: /etc/calamares/modules/unpackfs/exclude-list.txt diff --git a/calamares/src/modules/unpackfs/unpackfs.schema.yaml b/calamares/src/modules/unpackfs/unpackfs.schema.yaml new file mode 100644 index 0000000..9dc53c4 --- /dev/null +++ b/calamares/src/modules/unpackfs/unpackfs.schema.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/unpackfs +additionalProperties: false +type: object +properties: + unpack: + type: array + items: + type: object + additionalProperties: false + properties: + source: { type: string } + sourcefs: { type: string } + destination: { type: string } + excludeFile: { type: string } + exclude: { type: array, items: { type: string } } + weight: { type: integer, exclusiveMinimum: 0 } + optional: { type: boolean } + condition: + anyOf: + - type: boolean + - type: string + required: [ source , sourcefs, destination ] diff --git a/calamares/src/modules/unpackfsc/CMakeLists.txt b/calamares/src/modules/unpackfsc/CMakeLists.txt new file mode 100644 index 0000000..d38f064 --- /dev/null +++ b/calamares/src/modules/unpackfsc/CMakeLists.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2021 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later + +calamares_add_plugin( unpackfsc + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + UnpackFSCJob.cpp + # The workers for differently-packed filesystems + Runners.cpp + ErofsRunner.cpp + FSArchiverRunner.cpp + TarballRunner.cpp + UnsquashRunner.cpp + SHARED_LIB +) diff --git a/calamares/src/modules/unpackfsc/ErofsRunner.cpp b/calamares/src/modules/unpackfsc/ErofsRunner.cpp new file mode 100644 index 0000000..e75791f --- /dev/null +++ b/calamares/src/modules/unpackfsc/ErofsRunner.cpp @@ -0,0 +1,109 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2025 Kel Modderman + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ErofsRunner.h" + +#include +#include +#include + +#include + +Calamares::JobResult +ErofsRunner::run() +{ + if ( !checkSourceExists() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid erofs configuration" ), + tr( "The source archive %1 does not exist." ).arg( m_source ), + Calamares::JobResult::InvalidConfiguration ); + } + + const QString dumpErofsToolName = QStringLiteral( "dump.erofs" ); + QString dumpErofsExecutable; + if ( !checkToolExists( dumpErofsToolName, dumpErofsExecutable ) ) + { + return Calamares::JobResult::internalError( + tr( "Missing tools" ), + tr( "The %1 tool is not installed on the system." ).arg( dumpErofsToolName ), + Calamares::JobResult::MissingRequirements ); + } + + const QString fsckErofsToolName = QStringLiteral( "fsck.erofs" ); + QString fsckErofsExecutable; + if ( !checkToolExists( fsckErofsToolName, fsckErofsExecutable ) ) + { + return Calamares::JobResult::internalError( + tr( "Missing tools" ), + tr( "The %1 tool is not installed on the system." ).arg( fsckErofsToolName ), + Calamares::JobResult::MissingRequirements ); + } + + const QString destinationPath = Calamares::System::instance()->targetPath( m_destination ); + if ( destinationPath.isEmpty() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid erofs configuration" ), + tr( "No destination could be found for %1." ).arg( m_destination ), + Calamares::JobResult::InvalidConfiguration ); + } + + // Get the stats (number of inodes) from the FS + { + m_inodes = -1; + Calamares::Utils::Runner r( { dumpErofsExecutable, QStringLiteral( "-s" ), m_source } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + QObject::connect( &r, + &decltype( r )::output, + [ & ]( QString line ) + { + if ( line.startsWith( "Filesystem inode count: " ) ) + { + m_inodes = line.split( ' ', SplitSkipEmptyParts ).last().toInt(); + } + } ); + /* ignored */ r.run(); + } + if ( m_inodes <= 0 ) + { + cWarning() << "No stats could be obtained from" << dumpErofsExecutable << "-s " + << m_source; + } + + // Now do the actual unpack + { + m_linesProcessed = 0; + Calamares::Utils::Runner r( { fsckErofsExecutable, + QStringLiteral( "-d9" ), + QStringLiteral( "--force" ), + QStringLiteral( "--extract=%1" ).arg( destinationPath ), + m_source } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + connect( &r, &decltype( r )::output, this, &ErofsRunner::erofsProgress ); + return r.run().explainProcess( fsckErofsToolName, std::chrono::seconds( 0 ) ); + } +} + +void +ErofsRunner::erofsProgress( QString line ) +{ + m_linesProcessed++; + m_linesSinceLastUIUpdate++; + if ( m_linesSinceLastUIUpdate > updateUIEveryNLines && line.contains( '/' ) ) + { + const QString pathname = line.split( '/', SplitSkipEmptyParts ).last().trimmed(); + if ( !pathname.isEmpty() ) + { + m_linesSinceLastUIUpdate = 0; + double p = m_inodes > 0 ? ( double( m_linesProcessed ) / double( m_inodes ) ) : 0.5; + Q_EMIT progress( p, tr( "Erofs path %1" ).arg( pathname ) ); + } + } +} diff --git a/calamares/src/modules/unpackfsc/ErofsRunner.h b/calamares/src/modules/unpackfsc/ErofsRunner.h new file mode 100644 index 0000000..f6522be --- /dev/null +++ b/calamares/src/modules/unpackfsc/ErofsRunner.h @@ -0,0 +1,38 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2025 Kel Modderman + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UNPACKFSC_EROFSRUNNER_H +#define UNPACKFSC_EROFSRUNNER_H + +#include "Runners.h" + +/** @brief Use erofs-utils for extracting a erofs filesystem + * + */ +class ErofsRunner : public Runner +{ + Q_OBJECT +public: + using Runner::Runner; + + Calamares::JobResult run() override; + +protected Q_SLOTS: + void erofsProgress( QString line ); + +private: + int m_inodes = 0; // Total in the FS + + // Progress reporting + static constexpr const int updateUIEveryNLines = 107; + int m_linesProcessed = 0; + int m_linesSinceLastUIUpdate = 0; +}; + +#endif diff --git a/calamares/src/modules/unpackfsc/FSArchiverRunner.cpp b/calamares/src/modules/unpackfsc/FSArchiverRunner.cpp new file mode 100644 index 0000000..41b6b71 --- /dev/null +++ b/calamares/src/modules/unpackfsc/FSArchiverRunner.cpp @@ -0,0 +1,117 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "FSArchiverRunner.h" + +#include "utils/Logger.h" +#include "utils/Runner.h" + +#include + +static constexpr const int chunk_size = 137; +static const QString& +toolName() +{ + static const QString name = QStringLiteral( "fsarchiver" ); + return name; +} + +void +FSArchiverRunner::fsarchiverProgress( QString line ) +{ + m_since++; + // Typical line of output is this: + // -[00][ 99%][REGFILEM] /boot/thing + // 5 9 ^21 + if ( m_since >= chunk_size && line.length() > 21 && line[ 5 ] == '[' && line[ 9 ] == '%' ) + { + m_since = 0; + double p = double( line.mid( 6, 3 ).toInt() ) / 100.0; + const QString filename = line.mid( 22 ); + Q_EMIT progress( p, filename ); + } +} + +Calamares::JobResult +FSArchiverRunner::checkPrerequisites( QString& fsarchiverExecutable ) const +{ + if ( !checkToolExists( toolName(), fsarchiverExecutable ) ) + { + return Calamares::JobResult::internalError( + tr( "Missing tools" ), + tr( "The %1 tool is not installed on the system." ).arg( toolName() ), + Calamares::JobResult::MissingRequirements ); + } + + if ( !checkSourceExists() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid fsarchiver configuration" ), + tr( "The source archive %1 does not exist." ).arg( m_source ), + Calamares::JobResult::InvalidConfiguration ); + } + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +FSArchiverRunner::checkDestination( QString& destinationPath ) const +{ + destinationPath = Calamares::System::instance()->targetPath( m_destination ); + if ( destinationPath.isEmpty() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid fsarchiver configuration" ), + tr( "No destination could be found for %1." ).arg( m_destination ), + Calamares::JobResult::InvalidConfiguration ); + } + + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +FSArchiverDirRunner::run() +{ + QString fsarchiverExecutable; + if ( auto res = checkPrerequisites( fsarchiverExecutable ); !res ) + { + return res; + } + QString destinationPath; + if ( auto res = checkDestination( destinationPath ); !res ) + { + return res; + } + + Calamares::Utils::Runner r( + { fsarchiverExecutable, QStringLiteral( "-v" ), QStringLiteral( "restdir" ), m_source, destinationPath } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + connect( &r, &decltype( r )::output, this, &FSArchiverDirRunner::fsarchiverProgress ); + return r.run().explainProcess( toolName(), std::chrono::seconds( 0 ) ); +} + +Calamares::JobResult +FSArchiverFSRunner::run() +{ + QString fsarchiverExecutable; + if ( auto res = checkPrerequisites( fsarchiverExecutable ); !res ) + { + return res; + } + QString destinationPath; + if ( auto res = checkDestination( destinationPath ); !res ) + { + return res; + } + + Calamares::Utils::Runner r( + { fsarchiverExecutable, QStringLiteral( "-v" ), QStringLiteral( "restfs" ), m_source, destinationPath } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + connect( &r, &decltype( r )::output, this, &FSArchiverFSRunner::fsarchiverProgress ); + return r.run().explainProcess( toolName(), std::chrono::seconds( 0 ) ); +} diff --git a/calamares/src/modules/unpackfsc/FSArchiverRunner.h b/calamares/src/modules/unpackfsc/FSArchiverRunner.h new file mode 100644 index 0000000..39be623 --- /dev/null +++ b/calamares/src/modules/unpackfsc/FSArchiverRunner.h @@ -0,0 +1,59 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UNPACKFSC_FSARCHIVERRUNNER_H +#define UNPACKFSC_FSARCHIVERRUNNER_H + +#include "Runners.h" + +/** @brief Base class for runners of FSArchiver + * + */ +class FSArchiverRunner : public Runner +{ + Q_OBJECT +public: + using Runner::Runner; + +protected Q_SLOTS: + void fsarchiverProgress( QString line ); + +protected: + /** @brief Checks prerequisites, sets full path of fsarchiver in @p executable + */ + Calamares::JobResult checkPrerequisites( QString& executable ) const; + Calamares::JobResult checkDestination( QString& destinationPath ) const; + + int m_since = 0; +}; + +/** @brief Running FSArchiver in **dir** mode + * + */ +class FSArchiverDirRunner : public FSArchiverRunner +{ +public: + using FSArchiverRunner::FSArchiverRunner; + + Calamares::JobResult run() override; +}; + +/** @brief Running FSArchiver in **dir** mode + * + */ +class FSArchiverFSRunner : public FSArchiverRunner +{ +public: + using FSArchiverRunner::FSArchiverRunner; + + Calamares::JobResult run() override; +}; + + +#endif diff --git a/calamares/src/modules/unpackfsc/Runners.cpp b/calamares/src/modules/unpackfsc/Runners.cpp new file mode 100644 index 0000000..3b38622 --- /dev/null +++ b/calamares/src/modules/unpackfsc/Runners.cpp @@ -0,0 +1,38 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Runners.h" + +#include +#include + +#include +#include + +Runner::Runner( const QString& source, const QString& destination ) + : m_source( source ) + , m_destination( destination ) +{ +} + +Runner::~Runner() { } + +bool +Runner::checkSourceExists() const +{ + QFileInfo fi( m_source ); + return fi.exists() && fi.isReadable(); +} + +bool +Runner::checkToolExists( const QString& toolName, QString& fullPath ) +{ + fullPath = QStandardPaths::findExecutable( toolName ); + return !fullPath.isEmpty(); +} diff --git a/calamares/src/modules/unpackfsc/Runners.h b/calamares/src/modules/unpackfsc/Runners.h new file mode 100644 index 0000000..e8f385c --- /dev/null +++ b/calamares/src/modules/unpackfsc/Runners.h @@ -0,0 +1,48 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UNPACKFSC_RUNNERS_H +#define UNPACKFSC_RUNNERS_H + +#include + +class Runner : public QObject +{ + Q_OBJECT + +public: + Runner( const QString& source, const QString& destination ); + ~Runner() override; + + virtual Calamares::JobResult run() = 0; + + /** @brief Check that the (configured) source file exists. + * + * Returns @c true if it's a file and readable. + */ + bool checkSourceExists() const; + + /** @brief Check that a named tool (executable) exists in the search path. + * + * Returns @c true if the tool is found and sets @p fullPath + * to the full path of that tool; returns @c false and clears + * @p fullPath otherwise. + */ + static bool checkToolExists( const QString& toolName, QString& fullPath ); + +Q_SIGNALS: + // See Calamares Job::progress + void progress( qreal percent, const QString& message ); + +protected: + QString m_source; + QString m_destination; +}; + +#endif diff --git a/calamares/src/modules/unpackfsc/TarballRunner.cpp b/calamares/src/modules/unpackfsc/TarballRunner.cpp new file mode 100644 index 0000000..79dbec9 --- /dev/null +++ b/calamares/src/modules/unpackfsc/TarballRunner.cpp @@ -0,0 +1,86 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "TarballRunner.h" + +#include +#include +#include + +#include + +static constexpr const int chunk_size = 107; + +Calamares::JobResult +TarballRunner::run() +{ + if ( !checkSourceExists() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid tarball configuration" ), + tr( "The source archive %1 does not exist." ).arg( m_source ), + Calamares::JobResult::InvalidConfiguration ); + } + + const QString toolName = QStringLiteral( "tar" ); + QString tarExecutable; + if ( !checkToolExists( toolName, tarExecutable ) ) + { + return Calamares::JobResult::internalError( + tr( "Missing tools" ), + tr( "The %1 tool is not installed on the system." ).arg( toolName ), + Calamares::JobResult::MissingRequirements ); + } + + const QString destinationPath = Calamares::System::instance()->targetPath( m_destination ); + if ( destinationPath.isEmpty() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid tarball configuration" ), + tr( "No destination could be found for %1." ).arg( m_destination ), + Calamares::JobResult::InvalidConfiguration ); + } + + // Get the stats (number of inodes) from the FS + { + m_total = 0; + Calamares::Utils::Runner r( { tarExecutable, QStringLiteral( "-tf" ), m_source } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + QObject::connect( &r, &decltype( r )::output, [ & ]( QString line ) { m_total++; } ); + /* ignored */ r.run(); + } + if ( m_total <= 0 ) + { + cWarning() << "No stats could be obtained from" << tarExecutable << "-tf" << m_source; + } + + // Now do the actual unpack + { + m_processed = 0; + m_since = 0; + Calamares::Utils::Runner r( + { tarExecutable, QStringLiteral( "-xpvf" ), m_source, QStringLiteral( "-C" ), destinationPath } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + connect( &r, &decltype( r )::output, this, &TarballRunner::tarballProgress ); + return r.run().explainProcess( toolName, std::chrono::seconds( 0 ) ); + } +} + +void +TarballRunner::tarballProgress( QString line ) +{ + m_processed++; + m_since++; + if ( m_since > chunk_size ) + { + m_since = 0; + double p = m_total > 0 ? ( double( m_processed ) / double( m_total ) ) : 0.5; + Q_EMIT progress( p, tr( "Tarball extract file %1" ).arg( line ) ); + } +} diff --git a/calamares/src/modules/unpackfsc/TarballRunner.h b/calamares/src/modules/unpackfsc/TarballRunner.h new file mode 100644 index 0000000..ae04ea8 --- /dev/null +++ b/calamares/src/modules/unpackfsc/TarballRunner.h @@ -0,0 +1,36 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UNPACKFSC_TARBALLRUNNER_H +#define UNPACKFSC_TARBALLRUNNER_H + +#include "Runners.h" + +/** @brief Use (GNU) tar for extracting a filesystem + * + */ +class TarballRunner : public Runner +{ + Q_OBJECT +public: + using Runner::Runner; + + Calamares::JobResult run() override; + +protected Q_SLOTS: + void tarballProgress( QString line ); + +private: + // Progress reporting + int m_total = 0; + int m_processed = 0; + int m_since = 0; +}; + +#endif diff --git a/calamares/src/modules/unpackfsc/UnpackFSCJob.cpp b/calamares/src/modules/unpackfsc/UnpackFSCJob.cpp new file mode 100644 index 0000000..f142b1a --- /dev/null +++ b/calamares/src/modules/unpackfsc/UnpackFSCJob.cpp @@ -0,0 +1,199 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "UnpackFSCJob.h" + +#include "ErofsRunner.h" +#include "FSArchiverRunner.h" +#include "TarballRunner.h" +#include "UnsquashRunner.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/RAII.h" +#include "utils/Variant.h" + +#include + +static const NamedEnumTable< UnpackFSCJob::Type > +typeNames() +{ + using T = UnpackFSCJob::Type; + // clang-format off + static const NamedEnumTable< T > names + { + { "none", T::None }, + { "erofs", T::Erofs }, + { "fsarchiver", T::FSArchive }, + { "fsarchive", T::FSArchive }, + { "fsa", T::FSArchive }, + { "fsa-dir", T::FSArchive }, + { "fsa-block", T::FSArchiveFS }, + { "fsa-fs", T::FSArchiveFS }, + { "squashfs", T::Squashfs }, + { "squash", T::Squashfs }, + { "unsquash", T::Squashfs }, + { "tar", T::Tarball }, + { "tarball", T::Tarball }, + { "tgz", T::Tarball }, + }; + // clang-format on + return names; +} + +UnpackFSCJob::UnpackFSCJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +UnpackFSCJob::~UnpackFSCJob() {} + +QString +UnpackFSCJob::prettyName() const +{ + return tr( "Unpack filesystems" ); +} + +QString +UnpackFSCJob::prettyStatusMessage() const +{ + return m_progressMessage; +} + +static bool +checkCondition( const QString& condition ) +{ + if ( condition.isEmpty() ) + { + return true; + } + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + bool ok = false; + const auto v = Calamares::lookup( gs, condition, ok ); + if ( !ok ) + { + cWarning() << "Item has condition '" << condition << "' which is not set at all (assuming 'true')."; + return true; + } + + if ( !v.canConvert< bool >() ) + { + cWarning() << "Item has condition '" << condition << "' with value" << v << "(assuming 'true')."; + return true; + } + + return v.toBool(); +} + +Calamares::JobResult +UnpackFSCJob::exec() +{ + if ( !checkCondition( m_condition ) ) + { + cDebug() << "Skipping item with condition '" << m_condition << "' which is set to false."; + return Calamares::JobResult::ok(); + } + + cScopedAssignment messageClearer( &m_progressMessage, QString() ); + std::unique_ptr< Runner > r; + switch ( m_type ) + { + case Type::Erofs: + r = std::make_unique< ErofsRunner >( m_source, m_destination ); + break; + case Type::FSArchive: + r = std::make_unique< FSArchiverDirRunner >( m_source, m_destination ); + break; + case Type::FSArchiveFS: + r = std::make_unique< FSArchiverFSRunner >( m_source, m_destination ); + break; + case Type::Squashfs: + r = std::make_unique< UnsquashRunner >( m_source, m_destination ); + break; + case Type::Tarball: + r = std::make_unique< TarballRunner >( m_source, m_destination ); + break; + case Type::None: + default: + cDebug() << "Nothing to do."; + return Calamares::JobResult::ok(); + } + + connect( r.get(), + &Runner::progress, + [ = ]( qreal percent, const QString& message ) + { + m_progressMessage = message; + Q_EMIT progress( percent ); + } ); + return r->run(); +} + +void +UnpackFSCJob::setConfigurationMap( const QVariantMap& map ) +{ + m_type = Type::None; + + const QString source = Calamares::getString( map, "source" ); + const QString sourceTypeName = Calamares::getString( map, "sourcefs" ); + if ( source.isEmpty() || sourceTypeName.isEmpty() ) + { + cWarning() << "Skipping item with bad source data:" << map; + return; + } + bool bogus = false; + Type sourceType = typeNames().find( sourceTypeName, bogus ); + if ( sourceType == Type::None ) + { + cWarning() << "Skipping item with source type None"; + return; + } + const QString destination = Calamares::getString( map, "destination" ); + if ( destination.isEmpty() ) + { + cWarning() << "Skipping item with empty destination"; + return; + } + const auto conditionKey = QStringLiteral( "condition" ); + if ( map.contains( conditionKey ) ) + { + const auto value = map[ conditionKey ]; + if ( Calamares::typeOf( value ) == Calamares::BoolVariantType ) + { + if ( !value.toBool() ) + { + cDebug() << "Skipping item with condition set to false."; + // Leave type set to None, which will be skipped later + return; + } + // Else the condition is true, and we're fine leaving the string empty because that defaults to true + } + else + { + const auto variable = value.toString(); + if ( variable.isEmpty() ) + { + cDebug() << "Skipping item with condition '" << value << "' that is empty (use 'true' instead)."; + return; + } + m_condition = variable; + } + } + + m_source = source; + m_destination = destination; + m_type = sourceType; +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( UnpackFSCFactory, registerPlugin< UnpackFSCJob >(); ) diff --git a/calamares/src/modules/unpackfsc/UnpackFSCJob.h b/calamares/src/modules/unpackfsc/UnpackFSCJob.h new file mode 100644 index 0000000..a30bff2 --- /dev/null +++ b/calamares/src/modules/unpackfsc/UnpackFSCJob.h @@ -0,0 +1,52 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UNPACKFSC_UNPACKFSCJOB_H +#define UNPACKFSC_UNPACKFSCJOB_H + +#include +#include +#include + +class PLUGINDLLEXPORT UnpackFSCJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + enum class Type + { + None, /// << Invalid + Erofs, + FSArchive, + FSArchiveFS, + Squashfs, + Tarball, + }; + + explicit UnpackFSCJob( QObject* parent = nullptr ); + ~UnpackFSCJob() override; + + QString prettyName() const override; + QString prettyStatusMessage() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_source; + QString m_destination; + Type m_type = Type::None; + QString m_progressMessage; + QString m_condition; ///< May be empty to express condition "true" +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( UnpackFSCFactory ) + +#endif diff --git a/calamares/src/modules/unpackfsc/UnsquashRunner.cpp b/calamares/src/modules/unpackfsc/UnsquashRunner.cpp new file mode 100644 index 0000000..738196c --- /dev/null +++ b/calamares/src/modules/unpackfsc/UnsquashRunner.cpp @@ -0,0 +1,100 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "UnsquashRunner.h" + +#include +#include +#include + +#include + +Calamares::JobResult +UnsquashRunner::run() +{ + if ( !checkSourceExists() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid unsquash configuration" ), + tr( "The source archive %1 does not exist." ).arg( m_source ), + Calamares::JobResult::InvalidConfiguration ); + } + + const QString toolName = QStringLiteral( "unsquashfs" ); + QString unsquashExecutable; + if ( !checkToolExists( toolName, unsquashExecutable ) ) + { + return Calamares::JobResult::internalError( + tr( "Missing tools" ), + tr( "The %1 tool is not installed on the system." ).arg( toolName ), + Calamares::JobResult::MissingRequirements ); + } + + const QString destinationPath = Calamares::System::instance()->targetPath( m_destination ); + if ( destinationPath.isEmpty() ) + { + return Calamares::JobResult::internalError( + tr( "Invalid unsquash configuration" ), + tr( "No destination could be found for %1." ).arg( m_destination ), + Calamares::JobResult::InvalidConfiguration ); + } + + // Get the stats (number of inodes) from the FS + { + m_inodes = -1; + Calamares::Utils::Runner r( { unsquashExecutable, QStringLiteral( "-s" ), m_source } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + QObject::connect( &r, + &decltype( r )::output, + [ & ]( QString line ) + { + if ( line.startsWith( "Number of inodes " ) ) + { + m_inodes = line.split( ' ', SplitSkipEmptyParts ).last().toInt(); + } + } ); + /* ignored */ r.run(); + } + if ( m_inodes <= 0 ) + { + cWarning() << "No stats could be obtained from" << unsquashExecutable << "-s " + << m_source; + } + + // Now do the actual unpack + { + m_linesProcessed = 0; + Calamares::Utils::Runner r( { unsquashExecutable, + QStringLiteral( "-i" ), // List files + QStringLiteral( "-f" ), // Force-overwrite + QStringLiteral( "-d" ), + destinationPath, + m_source } ); + r.setLocation( Calamares::Utils::RunLocation::RunInHost ).enableOutputProcessing(); + connect( &r, &decltype( r )::output, this, &UnsquashRunner::unsquashProgress ); + return r.run().explainProcess( toolName, std::chrono::seconds( 0 ) ); + } +} + +void +UnsquashRunner::unsquashProgress( QString line ) +{ + m_linesProcessed++; + m_linesSinceLastUIUpdate++; + if ( m_linesSinceLastUIUpdate > updateUIEveryNLines && line.contains( '/' ) ) + { + const QString filename = line.split( '/', SplitSkipEmptyParts ).last().trimmed(); + if ( !filename.isEmpty() ) + { + m_linesSinceLastUIUpdate = 0; + double p = m_inodes > 0 ? ( double( m_linesProcessed ) / double( m_inodes ) ) : 0.5; + Q_EMIT progress( p, tr( "Unsquash file %1" ).arg( filename ) ); + } + } +} diff --git a/calamares/src/modules/unpackfsc/UnsquashRunner.h b/calamares/src/modules/unpackfsc/UnsquashRunner.h new file mode 100644 index 0000000..40a6c83 --- /dev/null +++ b/calamares/src/modules/unpackfsc/UnsquashRunner.h @@ -0,0 +1,38 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef UNPACKFSC_UNSQUASHRUNNER_H +#define UNPACKFSC_UNSQUASHRUNNER_H + +#include "Runners.h" + +/** @brief Use Unsquash for extracting a filesystem + * + */ +class UnsquashRunner : public Runner +{ + Q_OBJECT +public: + using Runner::Runner; + + Calamares::JobResult run() override; + +protected Q_SLOTS: + void unsquashProgress( QString line ); + +private: + int m_inodes = 0; // Total in the FS + + // Progress reporting + static constexpr const int updateUIEveryNLines = 107; + int m_linesProcessed = 0; + int m_linesSinceLastUIUpdate = 0; +}; + +#endif diff --git a/calamares/src/modules/unpackfsc/tests/1.global b/calamares/src/modules/unpackfsc/tests/1.global new file mode 100644 index 0000000..064ac2a --- /dev/null +++ b/calamares/src/modules/unpackfsc/tests/1.global @@ -0,0 +1,2 @@ +--- +rootMountPoint: /tmp/fstest diff --git a/calamares/src/modules/unpackfsc/tests/1.job b/calamares/src/modules/unpackfsc/tests/1.job new file mode 100644 index 0000000..9c6e46d --- /dev/null +++ b/calamares/src/modules/unpackfsc/tests/1.job @@ -0,0 +1,4 @@ +--- +source: /tmp/src.fsa +sourcefs: fsarchive +destination: "/calasrc" diff --git a/calamares/src/modules/unpackfsc/unpackfsc.conf b/calamares/src/modules/unpackfsc/unpackfsc.conf new file mode 100644 index 0000000..b8780ac --- /dev/null +++ b/calamares/src/modules/unpackfsc/unpackfsc.conf @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Unpack a filesystem. Supported ways to "pack" the filesystem are: +# - erofs +# Enhanced Read-Only File System is a relatively new filesystem, introduced +# in 2019 with Linux 5.4, which is optimised for runtime performance. EROFS +# has compression options similar to that of SquashFS, however, in +# direct head to head comparison, as of 2025, the EROFS compression +# algorithms are not as performant; it takes longer to pack a filesystem and +# the compression size is not quite as good. EROFS is under active +# development. +# . +# Wikipedia: https://en.wikipedia.org/wiki/EROFS +# Upstream documentation: https://erofs.docs.kernel.org/en/latest/ +# . +# erofs-utils contains mkfs.erofs, fsck.erofs and dump.erofs utilities +# which are required to pack, inspect and extract erofs files. +# - fsarchiver in *savedir/restdir* mode (directories, not block devices) +# - squashfs +# SquashFS is a compressed read-only file system for Linux that was +# introduced in 2002. It supports deduplication and has mature and +# performant compression algorithms. +# . +# Wikipedia: https://en.wikipedia.org/wiki/SquashFS +# Upstream documentation: https://github.com/plougher/squashfs-tools +# . +# squashfs-tools contains mksquashfs and unsquashfs utilities which are +# required to pack and extract squashfs files. +# +# Configuration: +# +# from globalstorage: rootMountPoint +# from job configuration: the item to unpack +# + +--- +# This module is configured a lot like the items in the *unpackfs* +# module, but with only **one** item. Use multiple instances for +# unpacking more than one filesystem. +# +# There are the following **mandatory** keys: +# - *source* path relative to the live / intstalling system to the image +# - *sourcefs* the type of the source files; valid entries are +# - `none` (this entry is ignored; kind of useless) +# - `erofs` +# - `fsarchiver` +# Aliases of this are `fsarchive`, `fsa` and `fsa-dir`. Uses +# fsarchiver in "restdir" mode. +# - `fsarchiver-block` +# Aliases of this are `fsa-block` and `fsa-fs`. Uses fsarchiver +# in "restfs" mode. +# - `squashfs` +# Aliases of this are `squash` and `unsquash`. +# - `tar` +# - *destination* path relative to rootMountPoint (so in the target +# system) where this filesystem is unpacked. It may be an +# empty string, which effectively is / (the root) of the target +# system. +# +# +# There are the following **optional** keys: +# - *condition* sets a dynamic condition on unpacking the item in +# this job. This may be true or false (constant) or name a globalstorage +# value. Use '.' to separate parts of a globalstorage name if it is nested. +# Remember to quote names. +# +# A condition is used in e.g. stacked squashfses, where the user can select +# a specific install type. The default value of *condition* is true. + +source: /data/rootfs.fsa +sourcefs: fsarchiver +destination: "/" +# condition: true diff --git a/calamares/src/modules/unpackfsc/unpackfsc.schema.yaml b/calamares/src/modules/unpackfsc/unpackfsc.schema.yaml new file mode 100644 index 0000000..14493b6 --- /dev/null +++ b/calamares/src/modules/unpackfsc/unpackfsc.schema.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/unpackfsc +additionalProperties: false +type: object +properties: + unpack: + type: array + items: + type: object + additionalProperties: false + properties: + source: { type: string } + sourcefs: { type: string } + destination: { type: string } + condition: + anyOf: + - type: boolean + - type: string + required: [ source , sourcefs, destination ] diff --git a/calamares/src/modules/users/ActiveDirectoryJob.cpp b/calamares/src/modules/users/ActiveDirectoryJob.cpp new file mode 100644 index 0000000..97a9f3c --- /dev/null +++ b/calamares/src/modules/users/ActiveDirectoryJob.cpp @@ -0,0 +1,84 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2024 Simon Quigley + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "ActiveDirectoryJob.h" + +#include "Config.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/Permissions.h" +#include "utils/System.h" + +#include +#include +#include +#include +#include +#include + +ActiveDirectoryJob::ActiveDirectoryJob( const QString& adminLogin, + const QString& adminPassword, + const QString& domain, + const QString& ip ) + : Calamares::Job() + , m_adminLogin( adminLogin ) + , m_adminPassword( adminPassword ) + , m_domain( domain ) + , m_ip( ip ) +{ +} + +QString +ActiveDirectoryJob::prettyName() const +{ + return tr( "Enroll system in Active Directory", "@label" ); +} + +QString +ActiveDirectoryJob::prettyStatusMessage() const +{ + return tr( "Enrolling system in Active Directory…", "@status" ); +} + +Calamares::JobResult +ActiveDirectoryJob::exec() +{ + if ( !m_ip.isEmpty() ) + { + const QString hostsFilePath = Calamares::System::instance()->targetPath( QStringLiteral( "/etc/hosts" ) ); + QFile hostsFile( hostsFilePath ); + if ( hostsFile.open( QIODevice::Append | QIODevice::Text ) ) + { + QTextStream out( &hostsFile ); + out << m_ip << " " << m_domain << "\n"; + hostsFile.close(); + } + else + { + return Calamares::JobResult::error( "Failed to open /etc/hosts for writing." ); + } + } + + const QString installPath = Calamares::System::instance()->targetPath( QStringLiteral( "/" ) ); + auto r = Calamares::System::instance()->runCommand( + Calamares::System::RunLocation::RunInHost, + { "realm", "join", m_domain, "-U", m_adminLogin, "--install=" + installPath, "--verbose" }, + QString(), + m_adminPassword, + std::chrono::seconds( 30 ) ); + + + if ( r.getExitCode() == 0 ) + { + return Calamares::JobResult::ok(); + } + else + { + return Calamares::JobResult::error( QString( "Failed to join realm: %1" ).arg( r.getOutput() ) ); + } +} diff --git a/calamares/src/modules/users/ActiveDirectoryJob.h b/calamares/src/modules/users/ActiveDirectoryJob.h new file mode 100644 index 0000000..77fd740 --- /dev/null +++ b/calamares/src/modules/users/ActiveDirectoryJob.h @@ -0,0 +1,34 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2024 Simon Quigley + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef ACTIVEDIRECTORYJOB_H +#define ACTIVEDIRECTORYJOB_H + +#include "Job.h" + +class ActiveDirectoryJob : public Calamares::Job +{ + Q_OBJECT +public: + ActiveDirectoryJob( const QString& adminLogin, + const QString& adminPassword, + const QString& domain, + const QString& ip ); + QString prettyName() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + QString m_adminLogin; // Admin credentials to do the enrollment + QString m_adminPassword; + QString m_domain; + QString m_ip; +}; + +#endif /* ACTIVEDIRECTORYJOB_H */ diff --git a/calamares/src/modules/users/CMakeLists.txt b/calamares/src/modules/users/CMakeLists.txt new file mode 100644 index 0000000..92ea2da --- /dev/null +++ b/calamares/src/modules/users/CMakeLists.txt @@ -0,0 +1,142 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +find_package(${qtname} ${QT_VERSION} CONFIG REQUIRED Core DBus Network) +find_package(Crypt REQUIRED) + +# Provide modern alias for -lcrypt +add_library(crypt_crypt INTERFACE) +add_library(crypt::crypt ALIAS crypt_crypt) +if(Crypt_FOUND) + target_link_libraries(crypt_crypt INTERFACE ${CRYPT_LIBRARIES}) +endif() + +# Check for crypt_gensalt +if(Crypt_FOUND) + set(_old_CRL "${CMAKE_REQUIRED_LIBRARIES}") + list(APPEND CMAKE_REQUIRED_LIBRARIES crypt) + include(CheckSymbolExists) + check_symbol_exists(crypt_gensalt crypt.h HAS_CRYPT_GENSALT) + set(CMAKE_REQUIRED_LIBRARIES "${_old_CRL}") + + if(HAS_CRYPT_GENSALT) + target_compile_definitions(crypt_crypt INTERFACE HAVE_CRYPT_GENSALT) + endif() +endif() + +# Add optional libraries here +set(USER_EXTRA_LIB + ${kfname}::CoreAddons + ${qtname}::DBus + crypt::crypt +) + +find_package(LibPWQuality) +set_package_properties(LibPWQuality PROPERTIES PURPOSE "Extra checks of password quality") + +if(LibPWQuality_FOUND) + list(APPEND USER_EXTRA_LIB ${LibPWQuality_LIBRARIES}) + include_directories(${LibPWQuality_INCLUDE_DIRS}) + add_definitions(-DCHECK_PWQUALITY -DHAVE_LIBPWQUALITY) +endif() + +find_package(ICU COMPONENTS uc i18n) +set_package_properties(ICU PROPERTIES PURPOSE "Transliteration support for full name to username conversion") + +if(ICU_FOUND) + list(APPEND USER_EXTRA_LIB ICU::uc ICU::i18n) + include_directories(${ICU_INCLUDE_DIRS}) + add_definitions(-DHAVE_ICU) +endif() + +include_directories(${PROJECT_BINARY_DIR}/src/libcalamaresui) + +set(_users_src + # Jobs + ActiveDirectoryJob.cpp + CreateUserJob.cpp + MiscJobs.cpp + SetPasswordJob.cpp + SetHostNameJob.cpp + # Configuration + CheckPWQuality.cpp + Config.cpp +) + +# This part of the code is shared with the usersq module +add_library(users_internal + OBJECT + ${_users_src} +) +target_link_libraries(users_internal + PRIVATE + ${USER_EXTRA_LIB} + ${Calamares_LIBRARIES} + ${qtname}::Core + ${qtname}::Gui + ${qtname}::Widgets +) +target_compile_definitions(users_internal PUBLIC PLUGINDLLEXPORT_PRO) +target_compile_options(users_internal PUBLIC -fPIC) +calamares_automoc(users_internal) + +calamares_add_plugin(users + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + UsersViewStep.cpp + UsersPage.cpp + UI + page_usersetup.ui + RESOURCES + users.qrc + LINK_PRIVATE_LIBRARIES + users_internal + crypt::crypt + ${USER_EXTRA_LIB} + SHARED_LIB +) + +if(NOT HAS_CRYPT_GENSALT) + # Test checks characteristics of the generated hash, but + # when HAVE_CRYPT_GENSALT is used, the chosen hash is the "best" + # one -- difficult to set expectations in the tests, so skip it. + calamares_add_test(userspasswordtest SOURCES TestPasswordJob.cpp SetPasswordJob.cpp LIBRARIES crypt::crypt) +endif() + +calamares_add_test( + usersgroupstest + SOURCES + TestGroupInformation.cpp + ${_users_src} # Build again with test-visibility + LIBRARIES + ${kfname}::CoreAddons + ${qtname}::DBus # HostName job can use DBus to systemd + crypt::crypt # SetPassword job uses crypt() + ${USER_EXTRA_LIB} +) + +calamares_add_test( + usershostnametest + SOURCES + TestSetHostNameJob.cpp + SetHostNameJob.cpp + ${_users_src} # Build again with test-visibility + LIBRARIES + ${qtname}::DBus # HostName job can use DBus to systemd + ${USER_EXTRA_LIB} +) + +calamares_add_test( + userstest + SOURCES + Tests.cpp + ${_users_src} # Build again with test-visibility + LIBRARIES + ${kfname}::CoreAddons + ${qtname}::DBus # HostName job can use DBus to systemd + crypt::crypt # SetPassword job uses crypt() + ${USER_EXTRA_LIB} +) diff --git a/calamares/src/modules/users/CheckPWQuality.cpp b/calamares/src/modules/users/CheckPWQuality.cpp new file mode 100644 index 0000000..f657f8f --- /dev/null +++ b/calamares/src/modules/users/CheckPWQuality.cpp @@ -0,0 +1,400 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Contains strings from libpwquality under the terms of the + * GPL-3.0-or-later (libpwquality is BSD-3-clause or GPL-2.0-or-later, + * so we pick GPL-3.0-or-later). + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "CheckPWQuality.h" + +#include "compat/Variant.h" +#include "utils/Logger.h" + +#include +#include + +#ifdef HAVE_LIBPWQUALITY +#include +#endif + +#include + +PasswordCheck::PasswordCheck() + : m_weight( 0 ) + , m_message() + , m_accept( []( const QString& ) { return true; } ) +{ +} + +PasswordCheck::PasswordCheck( MessageFunc m, AcceptFunc a, Weight weight ) + : m_weight( weight ) + , m_message( m ) + , m_accept( a ) +{ +} + +DEFINE_CHECK_FUNC( minLength ) +{ + int minLength = -1; + if ( value.canConvert< int >() ) + { + minLength = value.toInt(); + } + if ( minLength > 0 ) + { + cDebug() << Logger::SubEntry << "minLength set to" << minLength; + checks.push_back( PasswordCheck( []() { return QCoreApplication::translate( "PWQ", "Password is too short" ); }, + [ minLength ]( const QString& s ) { return s.length() >= minLength; }, + PasswordCheck::Weight( 10 ) ) ); + } +} + +DEFINE_CHECK_FUNC( maxLength ) +{ + int maxLength = -1; + if ( value.canConvert< int >() ) + { + maxLength = value.toInt(); + } + if ( maxLength > 0 ) + { + cDebug() << Logger::SubEntry << "maxLength set to" << maxLength; + checks.push_back( PasswordCheck( []() { return QCoreApplication::translate( "PWQ", "Password is too long" ); }, + [ maxLength ]( const QString& s ) { return s.length() <= maxLength; }, + PasswordCheck::Weight( 10 ) ) ); + } +} + +#ifdef HAVE_LIBPWQUALITY +/* NOTE: + * + * The munge*() functions are here because libpwquality uses void* to + * represent user-data in callbacks and as a general "pass some parameter" + * type. These need to be munged to the right C++ type. + */ + +/// @brief Handle libpwquality using void* to represent a long +static inline long +mungeLong( void* p ) +{ + return static_cast< long >( reinterpret_cast< intptr_t >( p ) ); +} + +/// @brief Handle libpwquality using void* to represent a char* +static inline const char* +mungeString( void* p ) +{ + return reinterpret_cast< const char* >( p ); +} + +/** + * Class that acts as a RAII placeholder for pwquality_settings_t pointers. + * Gets a new pointer and ensures it is deleted only once; provides + * convenience functions for setting options and checking passwords. + */ +class PWSettingsHolder +{ +public: + static constexpr int arbitrary_minimum_strength = 40; + + PWSettingsHolder() + : m_settings( pwquality_default_settings() ) + { + } + + ~PWSettingsHolder() { pwquality_free_settings( m_settings ); } + + /// Sets an option via the configuration string @p v, = style. + int set( const QString& v ) { return pwquality_set_option( m_settings, v.toUtf8().constData() ); } + + /** @brief Checks the given password @p pwd against the current configuration + * + * Resets m_errorString and m_errorCount and then sets them appropriately + * so that explanation() can be called afterwards. Sets m_rv as well. + */ + + int check( const QString& pwd ) + { + void* auxerror = nullptr; + m_rv = pwquality_check( m_settings, pwd.toUtf8().constData(), nullptr, nullptr, &auxerror ); + + // Positive return values could be ignored; some negative ones + // place extra information in auxerror, which is a void* and + // which needs interpretation to long- or string-values. + m_errorCount = 0; + m_errorString = QString(); + + switch ( m_rv ) + { + case PWQ_ERROR_CRACKLIB_CHECK: + if ( auxerror ) + { + /* Here the string comes from cracklib, don't free? */ + m_errorString = mungeString( auxerror ); + } + break; + case PWQ_ERROR_MEM_ALLOC: + case PWQ_ERROR_UNKNOWN_SETTING: + case PWQ_ERROR_INTEGER: + case PWQ_ERROR_NON_INT_SETTING: + case PWQ_ERROR_NON_STR_SETTING: + if ( auxerror ) + { + m_errorString = mungeString( auxerror ); + free( auxerror ); + } + break; + case PWQ_ERROR_MIN_DIGITS: + case PWQ_ERROR_MIN_UPPERS: + case PWQ_ERROR_MIN_LOWERS: + case PWQ_ERROR_MIN_OTHERS: + case PWQ_ERROR_MIN_LENGTH: + case PWQ_ERROR_MIN_CLASSES: + case PWQ_ERROR_MAX_CONSECUTIVE: + case PWQ_ERROR_MAX_CLASS_REPEAT: + case PWQ_ERROR_MAX_SEQUENCE: + if ( auxerror ) + { + m_errorCount = mungeLong( auxerror ); + } + break; + default: + break; + } + + return m_rv; + } + + /** @brief Explain the results of the last call to check() + * + * This is roughly the same as the function pwquality_strerror, + * only with QStrings instead, and using the Qt translation scheme. + * It is used under the terms of the GNU GPL v3 or later, as + * allowed by the libpwquality license (LICENSES/GPLv2+-libpwquality) + */ + QString explanation() + { + if ( m_rv >= arbitrary_minimum_strength ) + { + return QString(); + } + if ( m_rv >= 0 ) + { + return QCoreApplication::translate( "PWQ", "Password is too weak" ); + } + + switch ( m_rv ) + { + case PWQ_ERROR_MEM_ALLOC: + if ( !m_errorString.isEmpty() ) + { + return QCoreApplication::translate( "PWQ", "Memory allocation error when setting '%1'" ) + .arg( m_errorString ); + } + return QCoreApplication::translate( "PWQ", "Memory allocation error" ); + case PWQ_ERROR_SAME_PASSWORD: + return QCoreApplication::translate( "PWQ", "The password is the same as the old one" ); + case PWQ_ERROR_PALINDROME: + return QCoreApplication::translate( "PWQ", "The password is a palindrome" ); + case PWQ_ERROR_CASE_CHANGES_ONLY: + return QCoreApplication::translate( "PWQ", "The password differs with case changes only" ); + case PWQ_ERROR_TOO_SIMILAR: + return QCoreApplication::translate( "PWQ", "The password is too similar to the old one" ); + case PWQ_ERROR_USER_CHECK: + return QCoreApplication::translate( "PWQ", "The password contains the user name in some form" ); + case PWQ_ERROR_GECOS_CHECK: + return QCoreApplication::translate( + "PWQ", "The password contains words from the real name of the user in some form" ); + case PWQ_ERROR_BAD_WORDS: + return QCoreApplication::translate( "PWQ", "The password contains forbidden words in some form" ); + case PWQ_ERROR_MIN_DIGITS: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", "The password contains fewer than %n digits", nullptr, m_errorCount ); + } + return QCoreApplication::translate( "PWQ", "The password contains too few digits" ); + case PWQ_ERROR_MIN_UPPERS: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", "The password contains fewer than %n uppercase letters", nullptr, m_errorCount ); + } + return QCoreApplication::translate( "PWQ", "The password contains too few uppercase letters" ); + case PWQ_ERROR_MIN_LOWERS: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", "The password contains fewer than %n lowercase letters", nullptr, m_errorCount ); + } + return QCoreApplication::translate( "PWQ", "The password contains too few lowercase letters" ); + case PWQ_ERROR_MIN_OTHERS: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", "The password contains fewer than %n non-alphanumeric characters", nullptr, m_errorCount ); + } + return QCoreApplication::translate( "PWQ", "The password contains too few non-alphanumeric characters" ); + case PWQ_ERROR_MIN_LENGTH: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", "The password is shorter than %n characters", nullptr, m_errorCount ); + } + return QCoreApplication::translate( "PWQ", "The password is too short" ); + case PWQ_ERROR_ROTATED: + return QCoreApplication::translate( "PWQ", "The password is a rotated version of the previous one" ); + case PWQ_ERROR_MIN_CLASSES: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", "The password contains fewer than %n character classes", nullptr, m_errorCount ); + } + return QCoreApplication::translate( "PWQ", "The password does not contain enough character classes" ); + case PWQ_ERROR_MAX_CONSECUTIVE: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", "The password contains more than %n same characters consecutively", nullptr, m_errorCount ); + } + return QCoreApplication::translate( "PWQ", "The password contains too many same characters consecutively" ); + case PWQ_ERROR_MAX_CLASS_REPEAT: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", + "The password contains more than %n characters of the same class consecutively", + nullptr, + m_errorCount ); + } + return QCoreApplication::translate( + "PWQ", "The password contains too many characters of the same class consecutively" ); + case PWQ_ERROR_MAX_SEQUENCE: + if ( m_errorCount ) + { + return QCoreApplication::translate( + "PWQ", + "The password contains monotonic sequence longer than %n characters", + nullptr, + m_errorCount ); + } + return QCoreApplication::translate( "PWQ", + "The password contains too long of a monotonic character sequence" ); + case PWQ_ERROR_EMPTY_PASSWORD: + return QCoreApplication::translate( "PWQ", "No password supplied" ); + case PWQ_ERROR_RNG: + return QCoreApplication::translate( "PWQ", "Cannot obtain random numbers from the RNG device" ); + case PWQ_ERROR_GENERATION_FAILED: + return QCoreApplication::translate( "PWQ", + "Password generation failed - required entropy too low for settings" ); + case PWQ_ERROR_CRACKLIB_CHECK: + if ( !m_errorString.isEmpty() ) + { + return QCoreApplication::translate( "PWQ", "The password fails the dictionary check - %1" ) + .arg( m_errorString ); + } + return QCoreApplication::translate( "PWQ", "The password fails the dictionary check" ); + case PWQ_ERROR_UNKNOWN_SETTING: + if ( !m_errorString.isEmpty() ) + { + return QCoreApplication::translate( "PWQ", "Unknown setting - %1" ).arg( m_errorString ); + } + return QCoreApplication::translate( "PWQ", "Unknown setting" ); + case PWQ_ERROR_INTEGER: + if ( !m_errorString.isEmpty() ) + { + return QCoreApplication::translate( "PWQ", "Bad integer value of setting - %1" ).arg( m_errorString ); + } + return QCoreApplication::translate( "PWQ", "Bad integer value" ); + case PWQ_ERROR_NON_INT_SETTING: + if ( !m_errorString.isEmpty() ) + { + return QCoreApplication::translate( "PWQ", "Setting %1 is not of integer type" ).arg( m_errorString ); + } + return QCoreApplication::translate( "PWQ", "Setting is not of integer type" ); + case PWQ_ERROR_NON_STR_SETTING: + if ( !m_errorString.isEmpty() ) + { + return QCoreApplication::translate( "PWQ", "Setting %1 is not of string type" ).arg( m_errorString ); + } + return QCoreApplication::translate( "PWQ", "Setting is not of string type" ); + case PWQ_ERROR_CFGFILE_OPEN: + return QCoreApplication::translate( "PWQ", "Opening the configuration file failed" ); + case PWQ_ERROR_CFGFILE_MALFORMED: + return QCoreApplication::translate( "PWQ", "The configuration file is malformed" ); + case PWQ_ERROR_FATAL_FAILURE: + return QCoreApplication::translate( "PWQ", "Fatal failure" ); + default: + return QCoreApplication::translate( "PWQ", "Unknown error" ); + } + } + +private: + QString m_errorString; ///< Textual error from last call to check() + int m_errorCount = 0; ///< Count (used in %n) error from last call to check() + int m_rv = 0; ///< Return value from libpwquality + + pwquality_settings_t* m_settings = nullptr; +}; + +DEFINE_CHECK_FUNC( libpwquality ) +{ + if ( !value.canConvert< QVariantList >() ) + { + cWarning() << "libpwquality settings is not a list"; + return; + } + + QVariantList l = value.toList(); + unsigned int requirement_count = 0; + auto settings = std::make_shared< PWSettingsHolder >(); + for ( const auto& v : l ) + { + if ( Calamares::typeOf( v ) == Calamares::StringVariantType ) + { + QString option = v.toString(); + int r = settings->set( option ); + if ( r ) + { + cWarning() << "unrecognized libpwquality setting" << option; + } + else + { + cDebug() << Logger::SubEntry << "libpwquality setting" << option; + ++requirement_count; + } + } + else + { + cWarning() << "unrecognized libpwquality setting" << v; + } + } + + /* Something actually added? */ + if ( requirement_count ) + { + checks.push_back( PasswordCheck( [ settings ]() { return settings->explanation(); }, + [ settings ]( const QString& s ) + { + int r = settings->check( s ); + if ( r < 0 ) + { + cWarning() << "libpwquality error" << r + << pwquality_strerror( nullptr, 256, r, nullptr ); + } + else if ( r < settings->arbitrary_minimum_strength ) + { + cDebug() << "Password strength" << r << "too low"; + } + return r >= settings->arbitrary_minimum_strength; + }, + PasswordCheck::Weight( 100 ) ) ); + } +} +#endif diff --git a/calamares/src/modules/users/CheckPWQuality.h b/calamares/src/modules/users/CheckPWQuality.h new file mode 100644 index 0000000..db2cba0 --- /dev/null +++ b/calamares/src/modules/users/CheckPWQuality.h @@ -0,0 +1,80 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CHECKPWQUALITY_H +#define CHECKPWQUALITY_H + +#include +#include +#include + +#include + +/** + * Support for (dynamic) checks on the password's validity. + * This can be used to implement password requirements like + * "at least 6 characters". Function addPasswordCheck() + * instantiates these and adds them to the list of checks. + */ +class PasswordCheck +{ +public: + /** Return true if the string is acceptable. */ + using AcceptFunc = std::function< bool( const QString& ) >; + using MessageFunc = std::function< QString() >; + + using Weight = size_t; + + /** @brief Generate a @p message if @p filter returns true + * + * When @p filter returns true on the proposed password, the + * password is accepted (by this check). If false, then the + * @p message will be shown to the user. + * + * @p weight is used to order the checks (low-weight goes first). + */ + PasswordCheck( MessageFunc message, AcceptFunc filter, Weight weight = 1000 ); + /** @brief Null check, always accepts, no message */ + PasswordCheck(); + + /** Applies this check to the given password string @p s + * and returns an empty string if the password is ok + * according to this filter. Returns a message describing + * what is wrong if not. + */ + QString filter( const QString& s ) const { return m_accept( s ) ? QString() : m_message(); } + + Weight weight() const { return m_weight; } + bool operator<( const PasswordCheck& other ) const { return weight() < other.weight(); } + +private: + Weight m_weight; + MessageFunc m_message; + AcceptFunc m_accept; +}; + +using PasswordCheckList = QVector< PasswordCheck >; + +/* Each of these functions adds a check (if possible) to the list + * of checks; they use the configuration value(s) from the + * variant. If the value doesn't make sense, each function + * may skip adding a check, and do nothing (it should log + * an error, though). + */ +#define DEFINE_CHECK_FUNC_impl( x ) add_check_##x( PasswordCheckList& checks, const QVariant& value ) +#define DEFINE_CHECK_FUNC( x ) void DEFINE_CHECK_FUNC_impl( x ) +#define DECLARE_CHECK_FUNC( x ) void DEFINE_CHECK_FUNC_impl( x ); + +DECLARE_CHECK_FUNC( minLength ) +DECLARE_CHECK_FUNC( maxLength ) +#ifdef HAVE_LIBPWQUALITY +DECLARE_CHECK_FUNC( libpwquality ) +#endif + +#endif diff --git a/calamares/src/modules/users/Config.cpp b/calamares/src/modules/users/Config.cpp new file mode 100644 index 0000000..09a3a82 --- /dev/null +++ b/calamares/src/modules/users/Config.cpp @@ -0,0 +1,1113 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "ActiveDirectoryJob.h" +#include "CreateUserJob.h" +#include "MiscJobs.h" +#include "SetHostNameJob.h" +#include "SetPasswordJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "compat/Variant.h" +#include "utils/Logger.h" +#include "utils/Permissions.h" +#include "utils/String.h" +#include "utils/StringExpander.h" +#include "utils/Variant.h" + +#include +#include +#include +#include +#include + +#ifdef HAVE_ICU +#include +#include + +//Needed for ICU to apply some transliteration ruleset. +//Still needs to be adjusted to fit the needs of the most of users +static const char TRANSLITERATOR_ID[] = "Russian-Latin/BGN;" + "Greek-Latin/UNGEGN;" + "Any-Latin;" + "Latin-ASCII"; +#endif + +#include + +static const QRegularExpression USERNAME_RX( "^[a-z_][a-z0-9_-]*[$]?$" ); // Note anchors begin and end +static constexpr const int USERNAME_MAX_LENGTH = 31; + +static const QRegularExpression HOSTNAME_RX( "^[a-zA-Z0-9][-a-zA-Z0-9_]*$" ); // Note anchors begin and end +static constexpr const int HOSTNAME_MIN_LENGTH = 2; +static constexpr const int HOSTNAME_MAX_LENGTH = 63; + +static void +updateGSAutoLogin( bool doAutoLogin, const QString& login ) +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( !gs ) + { + cWarning() << "No Global Storage available"; + return; + } + + if ( doAutoLogin && !login.isEmpty() ) + { + gs->insert( "autoLoginUser", login ); + } + else + { + gs->remove( "autoLoginUser" ); + } + + if ( login.isEmpty() ) + { + gs->remove( "username" ); + } + else + { + gs->insert( "username", login ); + } +} + +static const QStringList& +alwaysForbiddenLoginNames() +{ + static QStringList s { QStringLiteral( "root" ), QStringLiteral( "nobody" ) }; + return s; +} + +static const QStringList& +alwaysForbiddenHostNames() +{ + static QStringList s { QStringLiteral( "localhost" ) }; + return s; +} + +const NamedEnumTable< HostNameAction >& +hostnameActionNames() +{ + // *INDENT-OFF* + // clang-format off + static const NamedEnumTable< HostNameAction > names { + { QStringLiteral( "none" ), HostNameAction::None }, + { QStringLiteral( "etcfile" ), HostNameAction::EtcHostname }, + { QStringLiteral( "etc" ), HostNameAction::EtcHostname }, + { QStringLiteral( "hostnamed" ), HostNameAction::SystemdHostname }, + { QStringLiteral( "transient" ), HostNameAction::Transient }, + }; + // clang-format on + // *INDENT-ON* + + return names; +} + +Config::Config( QObject* parent ) + : Calamares::ModuleSystem::Config( parent ) + , m_forbiddenHostNames( alwaysForbiddenHostNames() ) + , m_forbiddenLoginNames( alwaysForbiddenLoginNames() ) +{ + emit readyChanged( m_isReady ); // false + + // Gang together all the changes of status to one readyChanged() signal + connect( this, &Config::hostnameStatusChanged, this, &Config::checkReady ); + connect( this, &Config::loginNameStatusChanged, this, &Config::checkReady ); + connect( this, &Config::fullNameChanged, this, &Config::checkReady ); + connect( this, &Config::userPasswordStatusChanged, this, &Config::checkReady ); + connect( this, &Config::rootPasswordStatusChanged, this, &Config::checkReady ); + connect( this, &Config::reuseUserPasswordForRootChanged, this, &Config::checkReady ); + connect( this, &Config::requireStrongPasswordsChanged, this, &Config::checkReady ); +} + +Config::~Config() {} + +void +Config::setUserShell( const QString& shell ) +{ + if ( !shell.isEmpty() && !shell.startsWith( '/' ) ) + { + cWarning() << "User shell" << shell << "is not an absolute path."; + return; + } + if ( shell != m_userShell ) + { + m_userShell = shell; + emit userShellChanged( shell ); + // The shell is put into GS as well. + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( gs ) + { + gs->insert( "userShell", shell ); + } + } +} + +static inline void +insertInGlobalStorage( const QString& key, const QString& group ) +{ + auto* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( !gs || group.isEmpty() ) + { + return; + } + gs->insert( key, group ); +} + +void +Config::setAutoLoginGroup( const QString& group ) +{ + if ( group != m_autoLoginGroup ) + { + m_autoLoginGroup = group; + insertInGlobalStorage( QStringLiteral( "autoLoginGroup" ), group ); + emit autoLoginGroupChanged( group ); + } +} + +QStringList +Config::groupsForThisUser() const +{ + QStringList l; + l.reserve( defaultGroups().size() + 2 ); + + for ( const auto& g : defaultGroups() ) + { + l << g.name(); + } + if ( doAutoLogin() && !autoLoginGroup().isEmpty() ) + { + l << autoLoginGroup(); + } + if ( !m_nopasswdGroup.isEmpty() && m_userPassword.isEmpty() ) + { + // The user has no password, which is allowed by the + // configuration in this distro, and there is a special + // group for passwordless-login. + l << m_nopasswdGroup; + } + + return l; +} + +void +Config::setSudoersGroup( const QString& group ) +{ + if ( group != m_sudoersGroup ) + { + m_sudoersGroup = group; + insertInGlobalStorage( QStringLiteral( "sudoersGroup" ), group ); + emit sudoersGroupChanged( group ); + } +} + +void +Config::setLoginName( const QString& login ) +{ + CONFIG_PREVENT_EDITING( QString, "loginName" ); + if ( login != m_loginName ) + { + m_customLoginName = !login.isEmpty(); + m_loginName = login; + updateGSAutoLogin( doAutoLogin(), login ); + emit loginNameChanged( login ); + emit loginNameStatusChanged( loginNameStatus() ); + } +} + +const QStringList& +Config::forbiddenLoginNames() const +{ + return m_forbiddenLoginNames; +} + +QString +Config::loginNameStatus() const +{ + // An empty login is "ok", even if it isn't really + if ( m_loginName.isEmpty() ) + { + return QString(); + } + + if ( m_loginName.length() > USERNAME_MAX_LENGTH ) + { + return tr( "Your username is too long." ); + } + + QRegularExpression validateFirstLetter( "^[a-z_]" ); + if ( m_loginName.indexOf( validateFirstLetter ) != 0 ) + { + return tr( "Your username must start with a lowercase letter or underscore." ); + } + if ( m_loginName.indexOf( USERNAME_RX ) != 0 ) + { + return tr( "Only lowercase letters, numbers, underscore and hyphen are allowed." ); + } + + // Although we've made the list lower-case, and the RE above forces lower-case, still pass the flag + if ( forbiddenLoginNames().contains( m_loginName, Qt::CaseInsensitive ) ) + { + return tr( "'%1' is not allowed as username." ).arg( m_loginName ); + } + + return QString(); +} + +void +Config::setHostName( const QString& host ) +{ + if ( hostnameAction() != HostNameAction::EtcHostname && hostnameAction() != HostNameAction::SystemdHostname ) + { + cDebug() << "Ignoring hostname" << host << "No hostname will be set."; + return; + } + if ( host != m_hostname ) + { + m_customHostName = !host.isEmpty(); + m_hostname = host; + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( host.isEmpty() ) + { + gs->remove( "hostname" ); + } + else + { + gs->insert( "hostname", host ); + } + emit hostnameChanged( host ); + emit hostnameStatusChanged( hostnameStatus() ); + } +} + +const QStringList& +Config::forbiddenHostNames() const +{ + return m_forbiddenHostNames; +} + +QString +Config::hostnameStatus() const +{ + // An empty hostname is "ok", even if it isn't really + if ( m_hostname.isEmpty() ) + { + return QString(); + } + + if ( m_hostname.length() < HOSTNAME_MIN_LENGTH ) + { + return tr( "Your hostname is too short." ); + } + if ( m_hostname.length() > HOSTNAME_MAX_LENGTH ) + { + return tr( "Your hostname is too long." ); + } + + // "LocalHost" is just as forbidden as "localhost" + if ( forbiddenHostNames().contains( m_hostname, Qt::CaseInsensitive ) ) + { + return tr( "'%1' is not allowed as hostname." ).arg( m_hostname ); + } + + if ( m_hostname.indexOf( HOSTNAME_RX ) != 0 ) + { + return tr( "Only letters, numbers, underscore and hyphen are allowed." ); + } + + return QString(); +} + +static QString +cleanupForHostname( const QString& s ) +{ + QRegularExpression dmirx( "(^Apple|\\(.*\\)|[^a-zA-Z0-9])", QRegularExpression::CaseInsensitiveOption ); + return s.toLower().replace( dmirx, " " ).remove( ' ' ); +} + +/** @brief Guess the machine's name + * + * If there is DMI data, use that; otherwise, just call the machine "-pc". + * Reads the DMI data just once. + */ +static QString +guessProductName() +{ + static bool tried = false; + static QString dmiProduct; + + if ( !tried ) + { + QFile dmiFile( QStringLiteral( "/sys/devices/virtual/dmi/id/product_name" ) ); + QFile modelFile( QStringLiteral( "/proc/device-tree/model" ) ); + + if ( dmiFile.exists() && dmiFile.open( QIODevice::ReadOnly ) ) + { + dmiProduct = cleanupForHostname( QString::fromLocal8Bit( dmiFile.readAll().simplified().data() ) ); + if ( !dmiProduct.isEmpty() ) + { + tried = true; + return dmiProduct; + } + } + + if ( modelFile.exists() && modelFile.open( QIODevice::ReadOnly ) ) + { + dmiProduct + = cleanupForHostname( QString::fromLocal8Bit( modelFile.readAll().chopped( 1 ).simplified().data() ) ); + if ( !dmiProduct.isEmpty() ) + { + tried = true; + return dmiProduct; + } + } + + dmiProduct = QStringLiteral( "pc" ); + tried = true; + } + return dmiProduct; +} +#ifdef HAVE_ICU +static QString +transliterate( const QString& input ) +{ + static auto ue = UErrorCode::U_ZERO_ERROR; + static auto transliterator = std::unique_ptr< icu::Transliterator >( + icu::Transliterator::createInstance( TRANSLITERATOR_ID, UTRANS_FORWARD, ue ) ); + + if ( ue != UErrorCode::U_ZERO_ERROR ) + { + cWarning() << "Can't create transliterator"; + + //it'll be checked later for non-ASCII characters + return input; + } + + icu::UnicodeString transliterable( input.utf16() ); + transliterator->transliterate( transliterable ); + return QString::fromUtf16( transliterable.getTerminatedBuffer() ); +} +#else +static QString +transliterate( const QString& input ) +{ + return input; +} +#endif + +static QString +makeLoginNameSuggestion( const QStringList& parts ) +{ + if ( parts.isEmpty() || parts.first().isEmpty() ) + { + return QString(); + } + + QString usernameSuggestion = parts.first(); + for ( int i = 1; i < parts.length(); ++i ) + { + if ( !parts.value( i ).isEmpty() ) + { + usernameSuggestion.append( parts.value( i ).at( 0 ) ); + } + } + + return usernameSuggestion.indexOf( USERNAME_RX ) != -1 ? usernameSuggestion : QString(); +} + +/** @brief Return an invalid string for use in a hostname, if @p s is empty + * + * Maps empty to "^" (which is invalid in a hostname), everything else + * returns @p s itself. + */ +static QString +invalidEmpty( const QString& s ) +{ + return s.isEmpty() ? QStringLiteral( "^" ) : s; +} + +STATICTEST QString +makeHostnameSuggestion( const QString& templateString, const QStringList& fullNameParts, const QString& loginName ) +{ + Calamares::String::DictionaryExpander d; + // User data + d.add( QStringLiteral( "first" ), + invalidEmpty( fullNameParts.isEmpty() ? QString() : cleanupForHostname( fullNameParts.first() ) ) ) + .add( QStringLiteral( "name" ), invalidEmpty( cleanupForHostname( fullNameParts.join( QString() ) ) ) ) + .add( QStringLiteral( "login" ), invalidEmpty( cleanupForHostname( loginName ) ) ) + // Hardware data + .add( QStringLiteral( "product" ), guessProductName() ) + .add( QStringLiteral( "product2" ), cleanupForHostname( QSysInfo::prettyProductName() ) ) + .add( QStringLiteral( "cpu" ), cleanupForHostname( QSysInfo::currentCpuArchitecture() ) ) + // Hostname data + .add( QStringLiteral( "host" ), invalidEmpty( cleanupForHostname( QSysInfo::machineHostName() ) ) ); + + QString hostnameSuggestion = d.expand( templateString ); + + return hostnameSuggestion.indexOf( HOSTNAME_RX ) != -1 ? hostnameSuggestion : QString(); +} + +void +Config::setFullName( const QString& name ) +{ + CONFIG_PREVENT_EDITING( QString, "fullName" ); + + if ( name.isEmpty() && !m_fullName.isEmpty() ) + { + if ( !m_customHostName ) + { + setHostName( name ); + } + if ( !m_customLoginName ) + { + setLoginName( name ); + } + m_fullName = name; + emit fullNameChanged( name ); + } + + if ( name != m_fullName ) + { + m_fullName = name; + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( name.isEmpty() ) + { + gs->remove( "fullname" ); + } + else + { + gs->insert( "fullname", name ); + } + emit fullNameChanged( name ); + + // Build login and hostname, if needed + static QRegularExpression rx( "[^a-zA-Z0-9 ]" ); + + const QString cleanName = Calamares::String::removeDiacritics( transliterate( name ) ) + .replace( QRegularExpression( "[-']" ), "" ) + .replace( rx, " " ) + .toLower() + .simplified(); + + QStringList cleanParts = cleanName.split( ' ' ); + + if ( !m_customLoginName ) + { + const QString login = makeLoginNameSuggestion( cleanParts ); + if ( !login.isEmpty() && login != m_loginName ) + { + setLoginName( login ); + // It's **still** not custom, though setLoginName() sets that + m_customLoginName = false; + } + } + if ( !m_customHostName ) + { + const QString hostname = makeHostnameSuggestion( m_hostnameTemplate, cleanParts, loginName() ); + if ( !hostname.isEmpty() && hostname != m_hostname ) + { + setHostName( hostname ); + // Still not custom + m_customHostName = false; + } + } + } +} + +void +Config::setAutoLogin( bool b ) +{ + if ( b != m_doAutoLogin ) + { + m_doAutoLogin = b; + updateGSAutoLogin( b, loginName() ); + emit autoLoginChanged( b ); + } +} + +void +Config::setReuseUserPasswordForRoot( bool reuse ) +{ + if ( reuse != m_reuseUserPasswordForRoot ) + { + m_reuseUserPasswordForRoot = reuse; + emit reuseUserPasswordForRootChanged( reuse ); + { + auto rp = rootPasswordStatus(); + emit rootPasswordStatusChanged( rp.first, rp.second ); + } + } +} + +void +Config::setRequireStrongPasswords( bool strong ) +{ + if ( strong != m_requireStrongPasswords ) + { + m_requireStrongPasswords = strong; + emit requireStrongPasswordsChanged( strong ); + { + auto rp = rootPasswordStatus(); + emit rootPasswordStatusChanged( rp.first, rp.second ); + } + { + auto up = userPasswordStatus(); + emit userPasswordStatusChanged( up.first, up.second ); + } + } +} + +void +Config::setUserPassword( const QString& s ) +{ + if ( s != m_userPassword ) + { + m_userPassword = s; + const auto p = passwordStatus( m_userPassword, m_userPasswordSecondary ); + emit userPasswordStatusChanged( p.first, p.second ); + emit userPasswordChanged( s ); + } +} + +void +Config::setUserPasswordSecondary( const QString& s ) +{ + if ( s != m_userPasswordSecondary ) + { + m_userPasswordSecondary = s; + const auto p = passwordStatus( m_userPassword, m_userPasswordSecondary ); + emit userPasswordStatusChanged( p.first, p.second ); + emit userPasswordSecondaryChanged( s ); + } +} + +/** @brief Checks two copies of the password for validity + * + * Given two copies of the password -- generally the password and + * the secondary fields -- checks them for validity and returns + * a pair of . + * + */ +Config::PasswordStatus +Config::passwordStatus( const QString& pw1, const QString& pw2 ) const +{ + if ( pw1 != pw2 ) + { + return qMakePair( PasswordValidity::Invalid, tr( "Your passwords do not match!" ) ); + } + + bool failureIsFatal = requireStrongPasswords(); + for ( const auto& pc : m_passwordChecks ) + { + QString message = pc.filter( pw1 ); + + if ( !message.isEmpty() ) + { + return qMakePair( failureIsFatal ? PasswordValidity::Invalid : PasswordValidity::Weak, message ); + } + } + + return qMakePair( PasswordValidity::Valid, tr( "OK!" ) ); +} + +Config::PasswordStatus +Config::userPasswordStatus() const +{ + return passwordStatus( m_userPassword, m_userPasswordSecondary ); +} + +int +Config::userPasswordValidity() const +{ + auto p = userPasswordStatus(); + return p.first; +} + +QString +Config::userPasswordMessage() const +{ + auto p = userPasswordStatus(); + return p.second; +} + +void +Config::setRootPassword( const QString& s ) +{ + if ( writeRootPassword() && s != m_rootPassword ) + { + m_rootPassword = s; + const auto p = passwordStatus( m_rootPassword, m_rootPasswordSecondary ); + emit rootPasswordStatusChanged( p.first, p.second ); + emit rootPasswordChanged( s ); + } +} + +void +Config::setRootPasswordSecondary( const QString& s ) +{ + if ( writeRootPassword() && s != m_rootPasswordSecondary ) + { + m_rootPasswordSecondary = s; + const auto p = passwordStatus( m_rootPassword, m_rootPasswordSecondary ); + emit rootPasswordStatusChanged( p.first, p.second ); + emit rootPasswordSecondaryChanged( s ); + } +} + +void +Config::setActiveDirectoryUsed( bool used ) +{ + m_activeDirectoryUsed = used; +} + +bool +Config::getActiveDirectoryEnabled() const +{ + return m_activeDirectory; +} + +bool +Config::getActiveDirectoryUsed() const +{ + return m_activeDirectoryUsed && m_activeDirectory; +} + +void +Config::setActiveDirectoryAdminUsername( const QString& s ) +{ + m_activeDirectoryAdminUsername = s; +} + +void +Config::setActiveDirectoryAdminPassword( const QString& s ) +{ + m_activeDirectoryAdminPassword = s; +} + +void +Config::setActiveDirectoryDomain( const QString& s ) +{ + m_activeDirectoryDomain = s; +} + +void +Config::setActiveDirectoryIP( const QString& s ) +{ + m_activeDirectoryIP = s; +} + +QString +Config::rootPassword() const +{ + if ( writeRootPassword() ) + { + if ( reuseUserPasswordForRoot() ) + { + return userPassword(); + } + return m_rootPassword; + } + return QString(); +} + +QString +Config::rootPasswordSecondary() const +{ + if ( writeRootPassword() ) + { + if ( reuseUserPasswordForRoot() ) + { + return userPasswordSecondary(); + } + return m_rootPasswordSecondary; + } + return QString(); +} + +Config::PasswordStatus +Config::rootPasswordStatus() const +{ + if ( writeRootPassword() && !reuseUserPasswordForRoot() ) + { + return passwordStatus( m_rootPassword, m_rootPasswordSecondary ); + } + else + { + return userPasswordStatus(); + } +} + +int +Config::rootPasswordValidity() const +{ + auto p = rootPasswordStatus(); + return p.first; +} + +QString +Config::rootPasswordMessage() const +{ + auto p = rootPasswordStatus(); + return p.second; +} + +bool +Config::isReady() const +{ + bool readyFullName = !fullName().isEmpty(); // Needs some text + bool readyHostname = hostnameStatus().isEmpty(); // .. no warning message + bool readyUsername = !loginName().isEmpty() && loginNameStatus().isEmpty(); // .. no warning message + bool readyUserPassword = userPasswordValidity() != Config::PasswordValidity::Invalid; + bool readyRootPassword = rootPasswordValidity() != Config::PasswordValidity::Invalid; + return readyFullName && readyHostname && readyUsername && readyUserPassword && readyRootPassword; +} + +/** @brief Update ready status and emit signal + * + * This is a "concentrator" private slot for all the status-changed + * signals, so that readyChanged() is emitted only when needed. + */ +void +Config::checkReady() +{ + bool b = isReady(); + if ( b != m_isReady ) + { + m_isReady = b; + emit readyChanged( b ); + } +} + +STATICTEST void +setConfigurationDefaultGroups( const QVariantMap& map, QList< GroupDescription >& defaultGroups ) +{ + defaultGroups.clear(); + + const QString key( "defaultGroups" ); + auto groupsFromConfig = map.value( key ).toList(); + if ( groupsFromConfig.isEmpty() ) + { + if ( map.contains( key ) && map.value( key ).isValid() && map.value( key ).canConvert< QVariantList >() ) + { + // Explicitly set, but empty: this is valid, but unusual. + cDebug() << key << "has explicit empty value."; + } + else + { + // By default give the user a handful of "traditional" groups, if + // none are specified at all. These are system (GID < 1000) groups. + cWarning() << "Using fallback groups. Please check *defaultGroups* value in users.conf"; + for ( const auto& s : { "lp", "video", "network", "storage", "wheel", "audio" } ) + { + defaultGroups.append( + GroupDescription( s, GroupDescription::CreateIfNeeded {}, GroupDescription::SystemGroup {} ) ); + } + } + } + else + { + for ( const auto& v : groupsFromConfig ) + { + if ( Calamares::typeOf( v ) == Calamares::StringVariantType ) + { + defaultGroups.append( GroupDescription( v.toString() ) ); + } + else if ( Calamares::typeOf( v ) == Calamares::MapVariantType ) + { + const auto innermap = v.toMap(); + QString name = Calamares::getString( innermap, "name" ); + if ( !name.isEmpty() ) + { + defaultGroups.append( GroupDescription( name, + Calamares::getBool( innermap, "must_exist", false ), + Calamares::getBool( innermap, "system", false ) ) ); + } + else + { + cWarning() << "Ignoring *defaultGroups* entry without a name" << v; + } + } + else + { + cWarning() << "Unknown *defaultGroups* entry" << v; + } + } + } +} + +STATICTEST HostNameAction +getHostNameAction( const QVariantMap& configurationMap ) +{ + HostNameAction setHostName = HostNameAction::EtcHostname; + QString hostnameActionString = Calamares::getString( configurationMap, "location" ); + if ( !hostnameActionString.isEmpty() ) + { + bool ok = false; + setHostName = hostnameActionNames().find( hostnameActionString, ok ); + if ( !ok ) + { + setHostName = HostNameAction::EtcHostname; // Rather than none + } + } + + return setHostName; +} + +/** @brief Process entries in the passwordRequirements config entry + * + * Called once for each item in the config entry, which should + * be a key-value pair. What makes sense as a value depends on + * the key. Supported keys are documented in users.conf. + * + * @return if the check was added, returns @c true + */ +STATICTEST bool +addPasswordCheck( const QString& key, const QVariant& value, PasswordCheckList& passwordChecks ) +{ + if ( key == "minLength" ) + { + add_check_minLength( passwordChecks, value ); + } + else if ( key == "maxLength" ) + { + add_check_maxLength( passwordChecks, value ); + } + else if ( key == "nonempty" ) + { + cWarning() << "nonempty check is ignored; use minLength: 1"; + return false; + } +#ifdef CHECK_PWQUALITY + else if ( key == "libpwquality" ) + { + add_check_libpwquality( passwordChecks, value ); + } +#endif // CHECK_PWQUALITY + else + { + cWarning() << "Unknown password-check key" << key; + return false; + } + return true; +} + +/** @brief Returns a value of either key from the map + * + * Takes a function (e.g. getBool, or getString) and two keys, + * returning the value in the map of the one that is there (or @p defaultArg) + */ +template < typename T, typename U > +T +either( T ( *f )( const QVariantMap&, const QString&, U ), + const QVariantMap& configurationMap, + const QString& oldKey, + const QString& newKey, + U defaultArg ) +{ + if ( configurationMap.contains( oldKey ) ) + { + return f( configurationMap, oldKey, defaultArg ); + } + else + { + return f( configurationMap, newKey, defaultArg ); + } +} + +/** @brief Tidy up a list of names + * + * Remove duplicates, apply lowercase, sort. + */ +static void +tidy( QStringList& l ) +{ + std::for_each( l.begin(), l.end(), []( QString& s ) { s = s.toLower(); } ); + l.sort(); + l.removeDuplicates(); +} + +static QString +unscrambleYAML( const QVariant& v ) +{ + if ( Calamares::isIntegerVariantType( v ) ) + { + // YAML takes a string like "0755" and makes it an integer **anyway** + const auto number = v.toLongLong(); + if ( number < 0 ) + { + return QString(); + } + // Since YAML has parsed it as a decimal number, + // turn it back into the string representation of + // that decimal number, even though we intended it + // to be octal (e.g. "755" written down becomes + // seven-hundred-fifty-five, needs to be the string + // "755" again, even though we meant octal 755 which + // is four-hundred-ninety-three. + if ( number > 777 ) { return QString(); } + return QString::number( number ); + } + return v.toString(); +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + // Handle *user* key and subkeys and legacy settings + { + bool ok = false; // Ignored + QVariantMap userSettings = Calamares::getSubMap( configurationMap, "user", ok ); + + QString shell( QLatin1String( "/bin/bash" ) ); // as if it's not set at all + if ( userSettings.contains( "shell" ) ) + { + shell = Calamares::getString( userSettings, "shell" ); + } + // Now it might be explicitly set to empty, which is ok + setUserShell( shell ); + + m_forbiddenLoginNames = Calamares::getStringList( userSettings, "forbidden_names" ); + m_forbiddenLoginNames << alwaysForbiddenLoginNames(); + tidy( m_forbiddenLoginNames ); + + const auto permissionKey = QStringLiteral( "home_permissions" ); + if ( userSettings.contains( permissionKey ) ) + { + const auto value = unscrambleYAML( userSettings.value( permissionKey ) ); + m_homeDirPermissions = Calamares::parseFileMode( value ); + if ( m_homeDirPermissions < 0 ) + { + cWarning() << "Setting for" << permissionKey << '(' << value << userSettings[ permissionKey ] + << ") is invalid."; + } + } + else + { + m_homeDirPermissions = -1; + } + + m_nopasswdGroup = Calamares::getString( userSettings, "nopasswd_group" ); + } + + setAutoLoginGroup( either< QString, const QString& >( + Calamares::getString, configurationMap, "autologinGroup", "autoLoginGroup", QString() ) ); + setSudoersGroup( Calamares::getString( configurationMap, "sudoersGroup" ) ); + m_sudoStyle = Calamares::getBool( configurationMap, "sudoersConfigureWithGroup", false ) ? SudoStyle::UserAndGroup + : SudoStyle::UserOnly; + + // Handle Active Directory enablement + m_activeDirectory = Calamares::getBool( configurationMap, "allowActiveDirectory", false ); + + // Handle *hostname* key and subkeys and legacy settings + { + bool ok = false; // Ignored + QVariantMap hostnameSettings = Calamares::getSubMap( configurationMap, "hostname", ok ); + + m_hostnameAction = getHostNameAction( hostnameSettings ); + m_writeEtcHosts = Calamares::getBool( hostnameSettings, "writeHostsFile", true ); + m_hostnameTemplate + = Calamares::getString( hostnameSettings, "template", QStringLiteral( "${first}-${product}" ) ); + + m_forbiddenHostNames = Calamares::getStringList( hostnameSettings, "forbidden_names" ); + m_forbiddenHostNames << alwaysForbiddenHostNames(); + tidy( m_forbiddenHostNames ); + } + + setConfigurationDefaultGroups( configurationMap, m_defaultGroups ); + + // Renaming of Autologin -> AutoLogin in 4ffa79d4cf also affected + // configuration keys, which was not intended. Accept both. + m_displayAutoLogin = Calamares::getBool( configurationMap, "displayAutologin", false ); + m_doAutoLogin = either( + Calamares::getBool, configurationMap, QStringLiteral( "doAutologin" ), QStringLiteral( "doAutoLogin" ), false ); + + m_writeRootPassword = Calamares::getBool( configurationMap, "setRootPassword", true ); + Calamares::JobQueue::instance()->globalStorage()->insert( "setRootPassword", m_writeRootPassword ); + + m_reuseUserPasswordForRoot = Calamares::getBool( configurationMap, "doReusePassword", false ); + + m_permitWeakPasswords = Calamares::getBool( configurationMap, "allowWeakPasswords", false ); + m_requireStrongPasswords + = !m_permitWeakPasswords || !Calamares::getBool( configurationMap, "allowWeakPasswordsDefault", false ); + + // If the value doesn't exist, or isn't a map, this gives an empty map -- no problem + auto pr_checks( configurationMap.value( "passwordRequirements" ).toMap() ); + for ( decltype( pr_checks )::const_iterator i = pr_checks.constBegin(); i != pr_checks.constEnd(); ++i ) + { + addPasswordCheck( i.key(), i.value(), m_passwordChecks ); + } + std::sort( m_passwordChecks.begin(), m_passwordChecks.end() ); + + updateGSAutoLogin( doAutoLogin(), loginName() ); + checkReady(); + + ApplyPresets( *this, configurationMap ) << "fullName" + << "loginName"; +} + +void +Config::finalizeGlobalStorage() const +{ + updateGSAutoLogin( doAutoLogin(), loginName() ); + + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( writeRootPassword() ) + { + gs->insert( "reuseRootPassword", reuseUserPasswordForRoot() ); + } + gs->insert( "password", Calamares::String::obscure( userPassword() ) ); +} + +Calamares::JobList +Config::createJobs() const +{ + Calamares::JobList jobs; + + if ( !isReady() ) + { + return jobs; + } + + Calamares::Job* j; + + if ( !m_sudoersGroup.isEmpty() ) + { + j = new SetupSudoJob( m_sudoersGroup, m_sudoStyle ); + jobs.append( Calamares::job_ptr( j ) ); + } + + if ( getActiveDirectoryUsed() ) + { + j = new ActiveDirectoryJob( m_activeDirectoryAdminUsername, + m_activeDirectoryAdminPassword, + m_activeDirectoryDomain, + m_activeDirectoryIP ); + jobs.append( Calamares::job_ptr( j ) ); + } + + j = new SetupGroupsJob( this ); + jobs.append( Calamares::job_ptr( j ) ); + + j = new CreateUserJob( this ); + jobs.append( Calamares::job_ptr( j ) ); + + j = new SetPasswordJob( loginName(), userPassword() ); + jobs.append( Calamares::job_ptr( j ) ); + + j = new SetPasswordJob( "root", rootPassword() ); + jobs.append( Calamares::job_ptr( j ) ); + + j = new SetHostNameJob( this ); + jobs.append( Calamares::job_ptr( j ) ); + + return jobs; +} diff --git a/calamares/src/modules/users/Config.h b/calamares/src/modules/users/Config.h new file mode 100644 index 0000000..5ed3ce8 --- /dev/null +++ b/calamares/src/modules/users/Config.h @@ -0,0 +1,386 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef USERS_CONFIG_H +#define USERS_CONFIG_H + +#include "CheckPWQuality.h" + +#include "Job.h" +#include "modulesystem/Config.h" +#include "utils/NamedEnum.h" + +#include +#include +#include + +enum class HostNameAction +{ + None, + EtcHostname, // Write to /etc/hostname directly + SystemdHostname, // Set via hostnamed(1) + Transient, // Force target system transient, remove /etc/hostname +}; + +const NamedEnumTable< HostNameAction >& hostnameActionNames(); + +/** @brief Settings for a single group + * + * The list of defaultgroups from the configuration can be + * set up in a fine-grained way, with both user- and system- + * level groups; this class stores a configuration for each. + */ +class GroupDescription +{ +public: + // TODO: still too-weakly typed, add a macro to define strongly-typed bools + class MustExist : public std::true_type + { + }; + class CreateIfNeeded : public std::false_type + { + }; + class SystemGroup : public std::true_type + { + }; + class UserGroup : public std::false_type + { + }; + + ///@brief An invalid, empty group + GroupDescription() {} + + ///@brief A group with full details + GroupDescription( const QString& name, bool mustExistAlready = CreateIfNeeded {}, bool isSystem = UserGroup {} ) + : m_name( name ) + , m_isValid( !name.isEmpty() ) + , m_mustAlreadyExist( mustExistAlready ) + , m_isSystem( isSystem ) + { + } + + bool isValid() const { return m_isValid; } + bool isSystemGroup() const { return m_isSystem; } + bool mustAlreadyExist() const { return m_mustAlreadyExist; } + QString name() const { return m_name; } + + ///@brief Equality of groups depends only on name and kind + bool operator==( const GroupDescription& rhs ) const + { + return rhs.name() == name() && rhs.isSystemGroup() == isSystemGroup(); + } + +private: + QString m_name; + bool m_isValid = false; + bool m_mustAlreadyExist = false; + bool m_isSystem = false; +}; + + +class PLUGINDLLEXPORT Config : public Calamares::ModuleSystem::Config +{ + Q_OBJECT + + Q_PROPERTY( QString userShell READ userShell WRITE setUserShell NOTIFY userShellChanged ) + + Q_PROPERTY( QString nopasswdGroup READ nopasswdGroup CONSTANT ) + Q_PROPERTY( QString autoLoginGroup READ autoLoginGroup WRITE setAutoLoginGroup NOTIFY autoLoginGroupChanged ) + Q_PROPERTY( QString sudoersGroup READ sudoersGroup WRITE setSudoersGroup NOTIFY sudoersGroupChanged ) + + Q_PROPERTY( bool doAutoLogin READ doAutoLogin WRITE setAutoLogin NOTIFY autoLoginChanged ) + + Q_PROPERTY( QString fullName READ fullName WRITE setFullName NOTIFY fullNameChanged ) + Q_PROPERTY( QString loginName READ loginName WRITE setLoginName NOTIFY loginNameChanged ) + Q_PROPERTY( QString loginNameStatus READ loginNameStatus NOTIFY loginNameStatusChanged ) + + Q_PROPERTY( QString hostname READ hostname WRITE setHostName NOTIFY hostnameChanged ) + Q_PROPERTY( QString hostnameStatus READ hostnameStatus NOTIFY hostnameStatusChanged ) + Q_PROPERTY( HostNameAction hostnameAction READ hostnameAction CONSTANT ) + + Q_PROPERTY( QString userPassword READ userPassword WRITE setUserPassword NOTIFY userPasswordChanged ) + Q_PROPERTY( QString userPasswordSecondary READ userPasswordSecondary WRITE setUserPasswordSecondary NOTIFY + userPasswordSecondaryChanged ) + Q_PROPERTY( int userPasswordValidity READ userPasswordValidity NOTIFY userPasswordStatusChanged STORED false ) + Q_PROPERTY( QString userPasswordMessage READ userPasswordMessage NOTIFY userPasswordStatusChanged STORED false ) + + Q_PROPERTY( QString rootPassword READ rootPassword WRITE setRootPassword NOTIFY rootPasswordChanged ) + Q_PROPERTY( QString rootPasswordSecondary READ rootPasswordSecondary WRITE setRootPasswordSecondary NOTIFY + rootPasswordSecondaryChanged ) + Q_PROPERTY( int rootPasswordValidity READ rootPasswordValidity NOTIFY rootPasswordStatusChanged STORED false ) + Q_PROPERTY( QString rootPasswordMessage READ rootPasswordMessage NOTIFY rootPasswordStatusChanged STORED false ) + + Q_PROPERTY( bool writeRootPassword READ writeRootPassword CONSTANT ) + Q_PROPERTY( bool reuseUserPasswordForRoot READ reuseUserPasswordForRoot WRITE setReuseUserPasswordForRoot NOTIFY + reuseUserPasswordForRootChanged ) + + Q_PROPERTY( bool permitWeakPasswords READ permitWeakPasswords CONSTANT ) + Q_PROPERTY( bool requireStrongPasswords READ requireStrongPasswords WRITE setRequireStrongPasswords NOTIFY + requireStrongPasswordsChanged ) + + Q_PROPERTY( bool ready READ isReady NOTIFY readyChanged STORED false ) + +public: + /** @brief Validity (status) of a password + * + * Valid passwords are: + * - primary and secondary are equal **and** + * - all the password-strength checks pass + * Weak passwords: + * - primary and secondary are equal **and** + * - not all the checks pass **and** + * - permitWeakPasswords is @c true **and** + * - requireStrongPasswords is @c false + * Invalid passwords (all other cases): + * - the primary and secondary values are not equal **or** + * - not all the checks pass and weak passwords are not permitted + */ + enum PasswordValidity + { + Valid = 0, + Weak = 1, + Invalid = 2 + }; + + /** @brief Full password status + * + * A password's status is in two parts: + * - a validity (valid, weak or invalid) + * - a message describing that validity + * The message is empty when the password is valid, but + * weak and invalid passwords have an explanatory message. + */ + using PasswordStatus = QPair< PasswordValidity, QString >; + + Config( QObject* parent = nullptr ); + ~Config() override; + + void setConfigurationMap( const QVariantMap& ) override; + + /** @brief Fill Global Storage with some settings + * + * This should be called when moving on from the view step, + * and copies some things to GS that otherwise would not. + */ + void finalizeGlobalStorage() const; + + /** @brief Jobs for creating user, setting passwords + * + * If the Config object isn't ready yet, returns an empty list. + */ + Calamares::JobList createJobs() const; + + /** @brief Full path to the user's shell executable + * + * Typically this will be /bin/bash, but it can be set from + * the config file with the *userShell* setting. + */ + QString userShell() const { return m_userShell; } + + /// The group of which auto-login users must be a member + QString autoLoginGroup() const { return m_autoLoginGroup; } + + /// The group of which no-password users must be a member (applies only if the user somehow configures no password) + QString nopasswdGroup() const { return m_nopasswdGroup; } + + enum class SudoStyle + { + UserOnly, + UserAndGroup + }; + /// The group of which users who can "sudo" must be a member + QString sudoersGroup() const { return m_sudoersGroup; } + SudoStyle sudoStyle() const { return m_sudoStyle; } + + /// The full (GECOS) name of the user + QString fullName() const { return m_fullName; } + /// The login name of the user + QString loginName() const { return m_loginName; } + /// Status message about login -- empty for "ok" + QString loginNameStatus() const; + + /// The host name (name for the system) + QString hostname() const + { + return ( ( hostnameAction() == HostNameAction::EtcHostname ) + || ( hostnameAction() == HostNameAction::SystemdHostname ) ) + ? m_hostname + : QString(); + } + /// Status message about hostname -- empty for "ok" + QString hostnameStatus() const; + /// How to write the hostname + HostNameAction hostnameAction() const { return m_hostnameAction; } + /// Write /etc/hosts ? + bool writeEtcHosts() const { return m_writeEtcHosts; } + + /// Should the user be able to changed the value of autologin? + bool displayAutoLogin() const { return m_displayAutoLogin; } + /// Should the user be automatically logged-in? + bool doAutoLogin() const { return m_doAutoLogin; } + /// Should the root password be written (if false, no password is set and the root account is disabled for login) + bool writeRootPassword() const { return m_writeRootPassword; } + /// Should the user's password be used for root, too? (if root is written at all) + bool reuseUserPasswordForRoot() const { return m_reuseUserPasswordForRoot; } + /// Show UI to change the "require strong password" setting? + bool permitWeakPasswords() const { return m_permitWeakPasswords; } + /// Current setting for "require strong password"? + bool requireStrongPasswords() const { return m_requireStrongPasswords; } + /// Is Active Directory enabled in the config file? + bool getActiveDirectoryEnabled() const; + /// Is it both enabled and activated by user choice (checkbox)? + bool getActiveDirectoryUsed() const; + + const QList< GroupDescription >& defaultGroups() const { return m_defaultGroups; } + /** @brief the names of all the groups for the current user + * + * Takes into account defaultGroups and autoLogin behavior. + */ + QStringList groupsForThisUser() const; + + // The user enters a password (and again in a separate UI element) + QString userPassword() const { return m_userPassword; } + QString userPasswordSecondary() const { return m_userPasswordSecondary; } + int userPasswordValidity() const; + QString userPasswordMessage() const; + PasswordStatus userPasswordStatus() const; + + // The root password **may** be entered in the UI, or may be suppressed + // entirely when writeRootPassword is off, or may be equal to + // the user password when reuseUserPasswordForRoot is on. + QString rootPassword() const; + QString rootPasswordSecondary() const; + int rootPasswordValidity() const; + QString rootPasswordMessage() const; + PasswordStatus rootPasswordStatus() const; + + bool isReady() const; + + const QStringList& forbiddenLoginNames() const; + const QStringList& forbiddenHostNames() const; + + int homePermissions() const { return m_homeDirPermissions; } + int homeUMask() const { return m_homeDirPermissions >= 0 ? ( ( ~m_homeDirPermissions ) & 0777 ) : -1; } + +public Q_SLOTS: + /** @brief Sets the user's shell if possible + * + * If the path is empty, that's ok: no shell will be explicitly set, + * so the user will get whatever shell is set to default in the target. + * + * The given non-empty @p path must be an absolute path (for use inside + * the target system!); if it is not, the shell is not changed. + */ + void setUserShell( const QString& path ); + + /// Sets the autoLogin group; empty is ignored + void setAutoLoginGroup( const QString& group ); + /// Sets the sudoer group; empty is ignored + void setSudoersGroup( const QString& group ); + + /// Sets the full name, may guess a loginName + void setFullName( const QString& name ); + /// Sets the login name (flags it as "custom") + void setLoginName( const QString& login ); + + /// Sets the host name (flags it as "custom") + void setHostName( const QString& host ); + + /// Sets the autoLogin flag + void setAutoLogin( bool b ); + + /// Set to true to use the user password, unchanged, for root too + void setReuseUserPasswordForRoot( bool reuse ); + /// Change setting for "require strong password" + void setRequireStrongPasswords( bool strong ); + + void setUserPassword( const QString& ); + void setUserPasswordSecondary( const QString& ); + void setRootPassword( const QString& ); + void setRootPasswordSecondary( const QString& ); + + void setActiveDirectoryUsed( bool used ); + void setActiveDirectoryAdminUsername( const QString& ); + void setActiveDirectoryAdminPassword( const QString& ); + void setActiveDirectoryDomain( const QString& ); + void setActiveDirectoryIP( const QString& ); + +signals: + void userShellChanged( const QString& ); + void autoLoginGroupChanged( const QString& ); + void sudoersGroupChanged( const QString& ); + void fullNameChanged( const QString& ); + void loginNameChanged( const QString& ); + void loginNameStatusChanged( const QString& ); + void hostnameChanged( const QString& ); + void hostnameStatusChanged( const QString& ); + void autoLoginChanged( bool ); + void reuseUserPasswordForRootChanged( bool ); + void requireStrongPasswordsChanged( bool ); + void userPasswordChanged( const QString& ); + void userPasswordSecondaryChanged( const QString& ); + void userPasswordStatusChanged( int, const QString& ); + void rootPasswordChanged( const QString& ); + void rootPasswordSecondaryChanged( const QString& ); + void rootPasswordStatusChanged( int, const QString& ); + void readyChanged( bool ) const; + +private: + PasswordStatus passwordStatus( const QString&, const QString& ) const; + void checkReady(); + + QList< GroupDescription > m_defaultGroups; + QString m_userShell; + QString m_nopasswdGroup; + QString m_autoLoginGroup; + QString m_sudoersGroup; + SudoStyle m_sudoStyle = SudoStyle::UserOnly; + QString m_fullName; + QString m_loginName; + QString m_hostname; + + QString m_userPassword; + QString m_userPasswordSecondary; // enter again to be sure + QString m_rootPassword; + QString m_rootPasswordSecondary; + + bool m_displayAutoLogin = false; + bool m_doAutoLogin = false; + + bool m_writeRootPassword = true; + bool m_reuseUserPasswordForRoot = false; + + bool m_permitWeakPasswords = false; + bool m_requireStrongPasswords = true; + + bool m_customLoginName = false; + bool m_customHostName = false; + + bool m_isReady = false; ///< Used to reduce readyChanged signals + + bool m_activeDirectory = false; + bool m_activeDirectoryUsed = false; + QString m_activeDirectoryAdminUsername; + QString m_activeDirectoryAdminPassword; + QString m_activeDirectoryDomain; + QString m_activeDirectoryIP; + + HostNameAction m_hostnameAction = HostNameAction::EtcHostname; + bool m_writeEtcHosts = false; + QString m_hostnameTemplate; + + QStringList m_forbiddenHostNames; + QStringList m_forbiddenLoginNames; + + PasswordCheckList m_passwordChecks; + + int m_homeDirPermissions = -1; +}; + +#endif diff --git a/calamares/src/modules/users/CreateUserJob.cpp b/calamares/src/modules/users/CreateUserJob.cpp new file mode 100644 index 0000000..c55ee18 --- /dev/null +++ b/calamares/src/modules/users/CreateUserJob.cpp @@ -0,0 +1,172 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2016 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "CreateUserJob.h" + +#include "Config.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/Permissions.h" +#include "utils/System.h" + +#include +#include +#include +#include +#include + +CreateUserJob::CreateUserJob( const Config* config ) + : Calamares::Job() + , m_config( config ) +{ +} + +QString +CreateUserJob::prettyName() const +{ + return tr( "Create user %1" ).arg( m_config->loginName() ); +} + +QString +CreateUserJob::prettyDescription() const +{ + return tr( "Create user %1" ).arg( m_config->loginName() ); +} + +QString +CreateUserJob::prettyStatusMessage() const +{ + return m_status.isEmpty() ? tr( "Creating user %1…", "@status" ).arg( m_config->loginName() ) : m_status; +} + +static Calamares::JobResult +createUser( const QString& loginName, const QString& fullName, const QString& shell, int umask ) +{ + QStringList useraddCommand; +#ifdef __FreeBSD__ + useraddCommand << "pw" + << "useradd" + << "-n" << loginName << "-m" + << "-c" << fullName; + if ( !shell.isEmpty() ) + { + useraddCommand << "-s" << shell; + } + Q_UNUSED( umask ) +#else + useraddCommand << "useradd" + << "-m" + << "-U"; + if ( !shell.isEmpty() ) + { + useraddCommand << "-s" << shell; + } + useraddCommand << "-c" << fullName; + if ( umask >= 0 ) + { + // The QChar() is needed to disambiguate from the overload that takes a double + useraddCommand << "-K" << ( QStringLiteral( "UMASK=%1" ).arg( umask, 3, 8, QChar( '0' ) ) ); + } + useraddCommand << loginName; +#endif + + auto commandResult = Calamares::System::instance()->targetEnvCommand( useraddCommand ); + if ( commandResult.getExitCode() ) + { + cError() << "useradd failed" << commandResult.getExitCode(); + return commandResult.explainProcess( useraddCommand, std::chrono::seconds( 10 ) /* bogus timeout */ ); + } + return Calamares::JobResult::ok(); +} + +static Calamares::JobResult +setUserGroups( const QString& loginName, const QStringList& groups ) +{ + QStringList setgroupsCommand; +#ifdef __FreeBSD__ + setgroupsCommand << "pw" + << "usermod" + << "-n" << loginName << "-G" << groups.join( ',' ); +#else + setgroupsCommand << "usermod" + << "-aG" << groups.join( ',' ) << loginName; +#endif + + auto commandResult = Calamares::System::instance()->targetEnvCommand( setgroupsCommand ); + if ( commandResult.getExitCode() ) + { + cError() << "usermod failed" << commandResult.getExitCode(); + return commandResult.explainProcess( setgroupsCommand, std::chrono::seconds( 10 ) /* bogus timeout */ ); + } + return Calamares::JobResult::ok(); +} + +Calamares::JobResult +CreateUserJob::exec() +{ + QDir destDir; + bool reuseHome = false; + + { + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + destDir = QDir( gs->value( "rootMountPoint" ).toString() ); + reuseHome = gs->value( "reuseHome" ).toBool(); + } + + // If we're looking to reuse the contents of an existing /home. + // This GS setting comes from the **partitioning** module. + if ( reuseHome ) + { + m_status = tr( "Preserving home directory…", "@status" ); + emit progress( 0.2 ); + QString shellFriendlyHome = "/home/" + m_config->loginName(); + QDir existingHome( destDir.absolutePath() + shellFriendlyHome ); + if ( existingHome.exists() ) + { + QString backupDirName = "dotfiles_backup_" + QDateTime::currentDateTime().toString( "yyyy-MM-dd_HH-mm-ss" ); + existingHome.mkdir( backupDirName ); + + // We need the extra `sh -c` here to ensure that we can expand the shell globs + Calamares::System::instance()->targetEnvCall( + { "sh", "-c", "mv -f " + shellFriendlyHome + "/.* " + shellFriendlyHome + "/" + backupDirName } ); + } + } + + cDebug() << "[CREATEUSER]: creating user"; + + m_status = tr( "Creating user %1…", "@status" ).arg( m_config->loginName() ); + emit progress( 0.5 ); + auto useraddResult + = createUser( m_config->loginName(), m_config->fullName(), m_config->userShell(), m_config->homeUMask() ); + if ( !useraddResult ) + { + return useraddResult; + } + + m_status = tr( "Configuring user %1", "@status" ).arg( m_config->loginName() ); + emit progress( 0.8 ); + auto usergroupsResult = setUserGroups( m_config->loginName(), m_config->groupsForThisUser() ); + if ( !usergroupsResult ) + { + return usergroupsResult; + } + + m_status = tr( "Setting file permissions…", "@status" ); + emit progress( 0.9 ); + QString userGroup = QString( "%1:%2" ).arg( m_config->loginName() ).arg( m_config->loginName() ); + QString homeDir = QString( "/home/%1" ).arg( m_config->loginName() ); + auto commandResult = Calamares::System::instance()->targetEnvCommand( { "chown", "-R", userGroup, homeDir } ); + if ( commandResult.getExitCode() ) + { + cError() << "chown failed" << commandResult.getExitCode(); + return commandResult.explainProcess( "chown", std::chrono::seconds( 10 ) /* bogus timeout */ ); + } + + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/users/CreateUserJob.h b/calamares/src/modules/users/CreateUserJob.h new file mode 100644 index 0000000..28a48c8 --- /dev/null +++ b/calamares/src/modules/users/CreateUserJob.h @@ -0,0 +1,32 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CREATEUSERJOB_H +#define CREATEUSERJOB_H + +#include "Job.h" + +class Config; + +class CreateUserJob : public Calamares::Job +{ + Q_OBJECT +public: + CreateUserJob( const Config* config ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + const Config* m_config; + QString m_status; +}; + +#endif /* CREATEUSERJOB_H */ diff --git a/calamares/src/modules/users/MiscJobs.cpp b/calamares/src/modules/users/MiscJobs.cpp new file mode 100644 index 0000000..75aba41 --- /dev/null +++ b/calamares/src/modules/users/MiscJobs.cpp @@ -0,0 +1,210 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "MiscJobs.h" + +#include "Config.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/Permissions.h" +#include "utils/System.h" + +#include +#include +#include + +SetupSudoJob::SetupSudoJob( const QString& group, Config::SudoStyle style ) + : m_sudoGroup( group ) + , m_sudoStyle( style ) +{ +} + +QString +SetupSudoJob::prettyName() const +{ + return tr( "Configuring
    sudo
    users…", "@status" ); +} + +static QString +designatorForStyle( Config::SudoStyle style ) +{ + switch ( style ) + { + case Config::SudoStyle::UserOnly: + return QStringLiteral( "(ALL)" ); + case Config::SudoStyle::UserAndGroup: + return QStringLiteral( "(ALL:ALL)" ); + } + __builtin_unreachable(); +} + +Calamares::JobResult +SetupSudoJob::exec() +{ + if ( m_sudoGroup.isEmpty() ) + { + cDebug() << "Skipping sudo 10-installer because the sudoGroup is empty."; + return Calamares::JobResult::ok(); + } + + // One % for the sudo format, keep it outside of the string to avoid accidental replacement + QString sudoersLine + = QChar( '%' ) + QString( "%1 ALL=%2 ALL\n" ).arg( m_sudoGroup, designatorForStyle( m_sudoStyle ) ); + auto fileResult = Calamares::System::instance()->createTargetFile( QStringLiteral( "/etc/sudoers.d/10-installer" ), + sudoersLine.toUtf8().constData(), + Calamares::System::WriteMode::Overwrite ); + + if ( fileResult ) + { + if ( !Calamares::Permissions::apply( fileResult.path(), 0440 ) ) + { + return Calamares::JobResult::error( tr( "Cannot chmod sudoers file." ) ); + } + } + else + { + return Calamares::JobResult::error( tr( "Cannot create sudoers file for writing." ) ); + } + + return Calamares::JobResult::ok(); +} + +STATICTEST QStringList +groupsInTargetSystem() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( !gs ) + { + return QStringList(); + } + QDir targetRoot( gs->value( "rootMountPoint" ).toString() ); + + QFileInfo groupsFi( targetRoot.absoluteFilePath( "etc/group" ) ); + QFile groupsFile( groupsFi.absoluteFilePath() ); + if ( !groupsFile.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + return QStringList(); + } + QString groupsData = QString::fromLocal8Bit( groupsFile.readAll() ); + QStringList groupsLines = groupsData.split( '\n' ); + QStringList::iterator it = groupsLines.begin(); + while ( it != groupsLines.end() ) + { + if ( it->startsWith( '#' ) ) + { + it = groupsLines.erase( it ); + continue; + } + int indexOfFirstToDrop = it->indexOf( ':' ); + if ( indexOfFirstToDrop < 1 ) + { + it = groupsLines.erase( it ); + continue; + } + it->truncate( indexOfFirstToDrop ); + ++it; + } + return groupsLines; +} + +/** @brief Create groups in target system as needed + * + * Given a list of groups that already exist, @p availableGroups, + * go through the @p wantedGroups and create each of them. Groups that + * fail, or which should have already been there, are added to + * @p missingGroups by name. + */ +static bool +ensureGroupsExistInTarget( const QList< GroupDescription >& wantedGroups, + const QStringList& availableGroups, + QStringList& missingGroups ) +{ + int failureCount = 0; + + for ( const auto& group : wantedGroups ) + { + if ( group.isValid() && !availableGroups.contains( group.name() ) ) + { + if ( group.mustAlreadyExist() ) + { + // Should have been there already: don't create it + missingGroups.append( group.name() ); + continue; + } + + QStringList cmd; +#ifdef __FreeBSD__ + if ( group.isSystemGroup() ) + { + cWarning() << "Ignoring must-be-a-system group for" << group.name() << "on FreeBSD"; + } + cmd = QStringList { "pw", "groupadd", "-n", group.name() }; +#else + cmd << QStringLiteral( "groupadd" ); + if ( group.isSystemGroup() ) + { + cmd << "--system"; + } + cmd << group.name(); +#endif + if ( Calamares::System::instance()->targetEnvCall( cmd ) ) + { + failureCount++; + missingGroups.append( group.name() + QChar( '*' ) ); + } + } + } + if ( !missingGroups.isEmpty() ) + { + cWarning() << "Missing groups in target system (* for groupadd failure):" << Logger::DebugList( missingGroups ); + } + return failureCount == 0; +} + +SetupGroupsJob::SetupGroupsJob( const Config* config ) + : m_config( config ) +{ +} + +QString +SetupGroupsJob::prettyName() const +{ + return tr( "Preparing groups…", "@status" ); +} + +Calamares::JobResult +SetupGroupsJob::exec() +{ + const auto& defaultGroups = m_config->defaultGroups(); + QStringList availableGroups = groupsInTargetSystem(); + QStringList missingGroups; + + if ( !ensureGroupsExistInTarget( defaultGroups, availableGroups, missingGroups ) ) + { + return Calamares::JobResult::error( tr( "Could not create groups in target system" ) ); + } + if ( !missingGroups.isEmpty() ) + { + return Calamares::JobResult::error( + tr( "Could not create groups in target system" ), + tr( "These groups are missing in the target system: %1" ).arg( missingGroups.join( ',' ) ) ); + } + + if ( m_config->doAutoLogin() && !m_config->autoLoginGroup().isEmpty() ) + { + const QString autoLoginGroup = m_config->autoLoginGroup(); + (void)ensureGroupsExistInTarget( + QList< GroupDescription >() << GroupDescription( autoLoginGroup ), availableGroups, missingGroups ); + } + + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/users/MiscJobs.h b/calamares/src/modules/users/MiscJobs.h new file mode 100644 index 0000000..57272aa --- /dev/null +++ b/calamares/src/modules/users/MiscJobs.h @@ -0,0 +1,50 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/**@file Various small jobs + * + * This file collects miscellaneous jobs that need to be run to prepare + * the system for the user-creation job. + */ + +#ifndef USERS_MISCJOBS_H +#define USERS_MISCJOBS_H + +#include "Config.h" + +#include "Job.h" + +class SetupSudoJob : public Calamares::Job +{ + Q_OBJECT +public: + SetupSudoJob( const QString& group, Config::SudoStyle style ); + QString prettyName() const override; + Calamares::JobResult exec() override; + +public: + QString m_sudoGroup; + Config::SudoStyle m_sudoStyle; +}; + +class SetupGroupsJob : public Calamares::Job +{ + Q_OBJECT + +public: + SetupGroupsJob( const Config* config ); + QString prettyName() const override; + Calamares::JobResult exec() override; + +public: + const Config* m_config; +}; + +#endif diff --git a/calamares/src/modules/users/SetHostNameJob.cpp b/calamares/src/modules/users/SetHostNameJob.cpp new file mode 100644 index 0000000..f08c1da --- /dev/null +++ b/calamares/src/modules/users/SetHostNameJob.cpp @@ -0,0 +1,161 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Rohan Garg + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SetHostNameJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include +#include +#include +#include +#include + +using WriteMode = Calamares::System::WriteMode; + +SetHostNameJob::SetHostNameJob( const Config* c ) + : Calamares::Job() + , m_config( c ) +{ +} + +QString +SetHostNameJob::prettyName() const +{ + return tr( "Set hostname %1" ).arg( m_config->hostname() ); +} + +QString +SetHostNameJob::prettyDescription() const +{ + return tr( "Set hostname %1." ).arg( m_config->hostname() ); +} + +QString +SetHostNameJob::prettyStatusMessage() const +{ + return tr( "Setting hostname %1…", "@status" ).arg( m_config->hostname() ); +} + +STATICTEST bool +setFileHostname( const QString& hostname ) +{ + return Calamares::System::instance()->createTargetFile( + QStringLiteral( "/etc/hostname" ), ( hostname + '\n' ).toUtf8(), WriteMode::Overwrite ); +} + +STATICTEST bool +writeFileEtcHosts( const QString& hostname ) +{ + // The actual hostname gets substituted in at %1 + const QString standard_hosts = QStringLiteral( R"(# Standard host addresses +127.0.0.1 localhost +::1 localhost ip6-localhost ip6-loopback +ff02::1 ip6-allnodes +ff02::2 ip6-allrouters +)" ); + const QString this_host = QStringLiteral( R"(# This host address +127.0.1.1 %1 +)" ); + + const QString etc_hosts = standard_hosts + ( hostname.isEmpty() ? QString() : this_host.arg( hostname ) ); + return Calamares::System::instance()->createTargetFile( + QStringLiteral( "/etc/hosts" ), etc_hosts.toUtf8(), WriteMode::Overwrite ); +} + +STATICTEST bool +setSystemdHostname( const QString& hostname ) +{ + QDBusInterface hostnamed( "org.freedesktop.hostname1", + "/org/freedesktop/hostname1", + "org.freedesktop.hostname1", + QDBusConnection::systemBus() ); + if ( !hostnamed.isValid() ) + { + cWarning() << "Interface" << hostnamed.interface() << "is not valid."; + return false; + } + + bool success = true; + // Static, writes /etc/hostname + { + QDBusReply< void > r = hostnamed.call( "SetStaticHostname", hostname, false ); + if ( !r.isValid() ) + { + cWarning() << "Could not set hostname through org.freedesktop.hostname1.SetStaticHostname." << r.error(); + success = false; + } + } + // Dynamic, updates kernel + { + QDBusReply< void > r = hostnamed.call( "SetHostname", hostname, false ); + if ( !r.isValid() ) + { + cWarning() << "Could not set hostname through org.freedesktop.hostname1.SetHostname." << r.error(); + success = false; + } + } + + return success; +} + +Calamares::JobResult +SetHostNameJob::exec() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + + if ( !gs || !gs->contains( "rootMountPoint" ) ) + { + cError() << "No rootMountPoint in global storage"; + return Calamares::JobResult::error( tr( "Internal Error" ) ); + } + + QString destDir = gs->value( "rootMountPoint" ).toString(); + if ( !QDir( destDir ).exists() ) + { + cError() << "rootMountPoint points to a dir which does not exist"; + return Calamares::JobResult::error( tr( "Internal Error" ) ); + } + + switch ( m_config->hostnameAction() ) + { + case HostNameAction::None: + break; + case HostNameAction::EtcHostname: + if ( !setFileHostname( m_config->hostname() ) ) + { + cError() << "Can't write to hostname file"; + return Calamares::JobResult::error( tr( "Cannot write hostname to target system" ) ); + } + break; + case HostNameAction::SystemdHostname: + // Does its own logging + setSystemdHostname( m_config->hostname() ); + break; + case HostNameAction::Transient: + Calamares::System::instance()->removeTargetFile( QStringLiteral( "/etc/hostname" ) ); + break; + } + + if ( m_config->writeEtcHosts() ) + { + if ( !writeFileEtcHosts( m_config->hostname() ) ) + { + cError() << "Can't write to hosts file"; + return Calamares::JobResult::error( tr( "Cannot write hostname to target system" ) ); + } + } + + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/users/SetHostNameJob.h b/calamares/src/modules/users/SetHostNameJob.h new file mode 100644 index 0000000..b32b1d7 --- /dev/null +++ b/calamares/src/modules/users/SetHostNameJob.h @@ -0,0 +1,33 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Rohan Garg + * SPDX-FileCopyrightText: 2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SETHOSTNAMEJOB_CPP_H +#define SETHOSTNAMEJOB_CPP_H + +#include "Config.h" + +#include "Job.h" + +class SetHostNameJob : public Calamares::Job +{ + Q_OBJECT +public: + SetHostNameJob( const Config* c ); + QString prettyName() const override; + QString prettyDescription() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; + +private: + const Config* m_config; +}; + +#endif // SETHOSTNAMEJOB_CPP_H diff --git a/calamares/src/modules/users/SetPasswordJob.cpp b/calamares/src/modules/users/SetPasswordJob.cpp new file mode 100644 index 0000000..ad1cebc --- /dev/null +++ b/calamares/src/modules/users/SetPasswordJob.cpp @@ -0,0 +1,112 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SetPasswordJob.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Entropy.h" +#include "utils/Logger.h" +#include "utils/System.h" + +#include + +#include + +#ifndef NO_CRYPT_H +#include +#endif +#include + +SetPasswordJob::SetPasswordJob( const QString& userName, const QString& newPassword ) + : Calamares::Job() + , m_userName( userName ) + , m_newPassword( newPassword ) +{ +} + +QString +SetPasswordJob::prettyName() const +{ + return tr( "Set password for user %1" ).arg( m_userName ); +} + +QString +SetPasswordJob::prettyStatusMessage() const +{ + return tr( "Setting password for user %1…", "@status" ).arg( m_userName ); +} + +#ifndef HAVE_CRYPT_GENSALT +/// Returns a modular hashing salt for method 6 (SHA512) with a 16 character random salt. +QString +SetPasswordJob::make_salt( int length ) +{ + Q_ASSERT( length >= 8 ); + Q_ASSERT( length <= 128 ); + + QString salt_string; + Calamares::EntropySource source = Calamares::getPrintableEntropy( length, salt_string ); + if ( salt_string.length() != length ) + { + cWarning() << "getPrintableEntropy returned string of length" << salt_string.length() << "expected" << length; + salt_string.truncate( length ); + } + if ( source != Calamares::EntropySource::URandom ) + { + cWarning() << "Entropy data for salt is low-quality."; + } + + salt_string.insert( 0, "$y$" ); + salt_string.append( '$' ); + return salt_string; +} +#endif + +Calamares::JobResult +SetPasswordJob::exec() +{ + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + QDir destDir( gs->value( "rootMountPoint" ).toString() ); + if ( !destDir.exists() ) + { + return Calamares::JobResult::error( tr( "Bad destination system path." ), + tr( "rootMountPoint is %1" ).arg( destDir.absolutePath() ) ); + } + + if ( m_userName == "root" && m_newPassword.isEmpty() ) //special case for disabling root account + { + int ec = Calamares::System::instance()->targetEnvCall( { "usermod", "-p", "!", m_userName } ); + if ( ec ) + { + return Calamares::JobResult::error( tr( "Cannot disable root account." ), + tr( "usermod terminated with error code %1." ).arg( ec ) ); + } + return Calamares::JobResult::ok(); + } + + QString salt; +#ifdef HAVE_CRYPT_GENSALT + salt = crypt_gensalt( NULL, 0, NULL, 0 ); +#else + salt = make_salt( 16 ); +#endif + + QString encrypted = QString::fromLatin1( crypt( m_newPassword.toUtf8(), salt.toUtf8() ) ); + + int ec = Calamares::System::instance()->targetEnvCall( { "usermod", "-p", encrypted, m_userName } ); + if ( ec ) + { + return Calamares::JobResult::error( tr( "Cannot set password for user %1." ).arg( m_userName ), + tr( "usermod terminated with error code %1." ).arg( ec ) ); + } + + return Calamares::JobResult::ok(); +} diff --git a/calamares/src/modules/users/SetPasswordJob.h b/calamares/src/modules/users/SetPasswordJob.h new file mode 100644 index 0000000..75647d4 --- /dev/null +++ b/calamares/src/modules/users/SetPasswordJob.h @@ -0,0 +1,34 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef SETPASSWORDJOB_H +#define SETPASSWORDJOB_H + +#include "Job.h" + + +class SetPasswordJob : public Calamares::Job +{ + Q_OBJECT +public: + SetPasswordJob( const QString& userName, const QString& newPassword ); + QString prettyName() const override; + QString prettyStatusMessage() const override; + Calamares::JobResult exec() override; +#ifndef HAVE_CRYPT_GENSALT + static QString make_salt( int length ); +#endif /* HAVE_CRYPT_GENSALT */ + +private: + QString m_userName; + QString m_newPassword; +}; + +#endif /* SETPASSWORDJOB_H */ diff --git a/calamares/src/modules/users/TestGroupInformation.cpp b/calamares/src/modules/users/TestGroupInformation.cpp new file mode 100644 index 0000000..d5bd88f --- /dev/null +++ b/calamares/src/modules/users/TestGroupInformation.cpp @@ -0,0 +1,177 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" +#include "CreateUserJob.h" +#include "MiscJobs.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/Yaml.h" + +#include +#include + +// Implementation details +extern QStringList groupsInTargetSystem(); // CreateUserJob + +class GroupTests : public QObject +{ + Q_OBJECT +public: + GroupTests(); + ~GroupTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testReadGroup(); + void testCreateGroup(); + + void testSudoGroup(); + void testJobCreation(); +}; + +GroupTests::GroupTests() {} + +void +GroupTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "Users test started."; + if ( !Calamares::JobQueue::instance() ) + { + (void)new Calamares::JobQueue(); + } + Calamares::JobQueue::instance()->globalStorage()->insert( "rootMountPoint", "/" ); +} + +void +GroupTests::testReadGroup() +{ + // Get the groups in the host system + QStringList groups = groupsInTargetSystem(); + QVERIFY( groups.count() > 2 ); +#ifdef __FreeBSD__ + QVERIFY( groups.contains( QStringLiteral( "wheel" ) ) ); +#else + QVERIFY( groups.contains( QStringLiteral( "root" ) ) ); +#endif + QVERIFY( groups.contains( QStringLiteral( "tty" ) ) ); + // openSUSE doesn't have "sys", KaOS doesn't have "nogroup" + QVERIFY( groups.contains( QStringLiteral( "sys" ) ) || groups.contains( QStringLiteral( "nogroup" ) ) ); + + for ( const QString& s : groups ) + { + QVERIFY( !s.isEmpty() ); + QVERIFY( !s.contains( '#' ) ); + } +} + +void +GroupTests::testCreateGroup() +{ + // BUILD_AS_TEST is the source-directory path + QFileInfo fi( QString( "%1/tests/5-issue-1523.conf" ).arg( BUILD_AS_TEST ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + QVERIFY( map.count() > 0 ); // Just that it loaded, one key *defaultGroups* + + Config c; + c.setConfigurationMap( map ); + + QCOMPARE( c.defaultGroups().count(), 4 ); + QVERIFY( c.defaultGroups().contains( QStringLiteral( "adm" ) ) ); + QVERIFY( c.defaultGroups().contains( QStringLiteral( "bar" ) ) ); + + Calamares::JobQueue::instance()->globalStorage()->insert( "rootMountPoint", "/" ); + + SetupGroupsJob j( &c ); + QVERIFY( !j.exec() ); // running as regular user this should fail +} + +void +GroupTests::testSudoGroup() +{ + // Test programmatic changes + { + Config c; + QSignalSpy spy( &c, &Config::sudoersGroupChanged ); + QCOMPARE( c.sudoersGroup(), QString() ); + c.setSudoersGroup( QStringLiteral( "wheel" ) ); + QCOMPARE( c.sudoersGroup(), QStringLiteral( "wheel" ) ); + QCOMPARE( spy.count(), 1 ); // Changed to wheel + // Do it again, no change + c.setSudoersGroup( QStringLiteral( "wheel" ) ); + QCOMPARE( c.sudoersGroup(), QStringLiteral( "wheel" ) ); + QCOMPARE( spy.count(), 1 ); + c.setSudoersGroup( QStringLiteral( "roue" ) ); + QCOMPARE( c.sudoersGroup(), QStringLiteral( "roue" ) ); + QCOMPARE( spy.count(), 2 ); + } + + // Test config loading + { + Config c; + QSignalSpy spy( &c, &Config::sudoersGroupChanged ); + QCOMPARE( c.sudoersGroup(), QString() ); + + QVariantMap m; + c.setConfigurationMap( m ); + QCOMPARE( c.sudoersGroup(), QString() ); + QCOMPARE( spy.count(), 0 ); // Unchanged + + const auto key = QStringLiteral( "sudoersGroup" ); + const auto v0 = QStringLiteral( "wheel" ); + const auto v1 = QStringLiteral( "roue" ); + m.insert( key, v0 ); + c.setConfigurationMap( m ); + QCOMPARE( c.sudoersGroup(), v0 ); + QCOMPARE( spy.count(), 1 ); + } +} + +/** @brief Are all the expected jobs (and no others) created? + * + * - A sudo job is created only when the sudoers group is set; + * - Groups job + * - User job + * - Password job + * - Root password job + * - Hostname job are always created. + */ +void +GroupTests::testJobCreation() +{ + const int expectedJobs = 5; + Config c; + QVERIFY( !c.isReady() ); + + // Needs some setup + c.setFullName( QStringLiteral( "Goodluck Jonathan" ) ); + c.setLoginName( QStringLiteral( "goodj" ) ); + QVERIFY( c.isReady() ); + + QCOMPARE( c.sudoersGroup(), QString() ); + QCOMPARE( c.createJobs().count(), expectedJobs ); + + c.setSudoersGroup( QStringLiteral( "wheel" ) ); + QCOMPARE( c.sudoersGroup(), QString( "wheel" ) ); + QCOMPARE( c.createJobs().count(), expectedJobs + 1 ); +} + +QTEST_GUILESS_MAIN( GroupTests ) + +#include "utils/moc-warnings.h" + +#include "TestGroupInformation.moc" diff --git a/calamares/src/modules/users/TestPasswordJob.cpp b/calamares/src/modules/users/TestPasswordJob.cpp new file mode 100644 index 0000000..18e9b8e --- /dev/null +++ b/calamares/src/modules/users/TestPasswordJob.cpp @@ -0,0 +1,55 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SetPasswordJob.h" + +#include + +class PasswordTests : public QObject +{ + Q_OBJECT +public: + PasswordTests(); + ~PasswordTests() override; + +private Q_SLOTS: + void initTestCase(); + void testSalt(); +}; + +PasswordTests::PasswordTests() {} + +PasswordTests::~PasswordTests() {} + +void +PasswordTests::initTestCase() +{ +} + +void +PasswordTests::testSalt() +{ + QString s = SetPasswordJob::make_salt( 8 ); + QCOMPARE( s.length(), 4 + 8 ); // 8 salt chars, plus $y$, plus trailing $ + QVERIFY( s.startsWith( "$y$" ) ); + QVERIFY( s.endsWith( '$' ) ); + qDebug() << "Obtained salt" << s; + + s = SetPasswordJob::make_salt( 11 ); + QCOMPARE( s.length(), 4 + 11 ); + QVERIFY( s.startsWith( "$y$" ) ); + QVERIFY( s.endsWith( '$' ) ); + qDebug() << "Obtained salt" << s; +} + +QTEST_GUILESS_MAIN( PasswordTests ) + +#include "utils/moc-warnings.h" + +#include "TestPasswordJob.moc" diff --git a/calamares/src/modules/users/TestSetHostNameJob.cpp b/calamares/src/modules/users/TestSetHostNameJob.cpp new file mode 100644 index 0000000..90e44de --- /dev/null +++ b/calamares/src/modules/users/TestSetHostNameJob.cpp @@ -0,0 +1,163 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "SetHostNameJob.h" + +// Implementation details +extern bool setFileHostname( const QString& ); +extern bool writeFileEtcHosts( const QString& ); +extern bool setSystemdHostname( const QString& ); + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include +#include + +#include + +class UsersTests : public QObject +{ + Q_OBJECT +public: + UsersTests(); + ~UsersTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testEtcHostname(); + void testEtcHosts(); + void testHostnamed(); + + void cleanup(); + +private: + QTemporaryDir m_dir; + QString m_originalHostName; +}; + +UsersTests::UsersTests() + : m_dir( QStringLiteral( "/tmp/calamares-usertest" ) ) +{ +} + +void +UsersTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "Users test started."; + cDebug() << "Test dir" << m_dir.path(); + + // Ensure we have a system object, expect it to be a "bogus" one + Calamares::System* system = Calamares::System::instance(); + QVERIFY( system ); + QVERIFY( system->doChroot() ); + + // Ensure we have a system-wide GlobalStorage with /tmp as root + if ( !Calamares::JobQueue::instance() ) + { + cDebug() << "Creating new JobQueue"; + (void)new Calamares::JobQueue(); + } + Calamares::GlobalStorage* gs + = Calamares::JobQueue::instance() ? Calamares::JobQueue::instance()->globalStorage() : nullptr; + QVERIFY( gs ); + gs->insert( "rootMountPoint", m_dir.path() ); + + if ( m_originalHostName.isEmpty() ) + { + QFile hostname( QStringLiteral( "/etc/hostname" ) ); + if ( hostname.exists() && hostname.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + m_originalHostName = hostname.readAll().trimmed(); + } + } +} + +void +UsersTests::testEtcHostname() +{ + cDebug() << "Test dir" << m_dir.path(); + + QVERIFY( QFile::exists( m_dir.path() ) ); + QVERIFY( !QFile::exists( m_dir.filePath( "etc" ) ) ); + + const QString testHostname = QStringLiteral( "tubophone.calamares.io" ); + // Doesn't create intermediate directories + QVERIFY( !setFileHostname( testHostname ) ); + + QVERIFY( Calamares::System::instance()->createTargetDirs( "/etc" ) ); + QVERIFY( QFile::exists( m_dir.filePath( "etc" ) ) ); + + // Does write the file + QVERIFY( setFileHostname( testHostname ) ); + QVERIFY( QFile::exists( m_dir.filePath( "etc/hostname" ) ) ); + + // 22 for the test string, above, and 1 for the newline + QCOMPARE( QFileInfo( m_dir.filePath( "etc/hostname" ) ).size(), testHostname.length() + 1 ); +} + +void +UsersTests::testEtcHosts() +{ + // Assume previous tests did their work + QVERIFY( QFile::exists( m_dir.path() ) ); + QVERIFY( QFile::exists( m_dir.filePath( "etc" ) ) ); + + const QString testHostname = QStringLiteral( "tubophone.calamares.io" ); + QVERIFY( writeFileEtcHosts( testHostname ) ); + QVERIFY( QFile::exists( m_dir.filePath( "etc/hosts" ) ) ); + // The skeleton contains %1 which has the hostname substituted in, so we lose two, + // and the rest of the blabla is 145 (the "standard" part) and 34 (the "for this host" part) + QCOMPARE( QFileInfo( m_dir.filePath( "etc/hosts" ) ).size(), 145 + 34 + testHostname.length() - 2 ); +} + +void +UsersTests::testHostnamed() +{ + // Since the service might not be running (e.g. non-systemd systems, + // FreeBSD, docker, ..) we're not going to fail a test here. + // There's also the permissions problem to think of. But if we're + // root, assume it will succeed. + if ( geteuid() != 0 ) + { + if ( !setSystemdHostname( QStringLiteral( "tubophone.calamares.io" ) ) ) + { + QEXPECT_FAIL( "", "Hostname changes are access-controlled", Continue ); + } + } + QVERIFY( setSystemdHostname( QStringLiteral( "tubophone.calamares.io" ) ) ); + if ( !m_originalHostName.isEmpty() ) + { + // If the previous test succeeded (to change the hostname to something bogus) + // then this one should, also; or, if the previous one failed, then this + // changes to whatever-the-hostname-is, and systemd dbus seems to call that + // a success, as well (since nothing changes). So no failure-expectation here. + setSystemdHostname( m_originalHostName ); + } +} + +void +UsersTests::cleanup() +{ + if ( QTest::currentTestFailed() ) + { + m_dir.setAutoRemove( false ); + } +} + +QTEST_GUILESS_MAIN( UsersTests ) + +#include "utils/moc-warnings.h" + +#include "TestSetHostNameJob.moc" diff --git a/calamares/src/modules/users/Tests.cpp b/calamares/src/modules/users/Tests.cpp new file mode 100644 index 0000000..237f375 --- /dev/null +++ b/calamares/src/modules/users/Tests.cpp @@ -0,0 +1,589 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/Yaml.h" + +#include + +// Implementation details +extern void setConfigurationDefaultGroups( const QVariantMap& map, QList< GroupDescription >& defaultGroups ); +extern HostNameAction getHostNameAction( const QVariantMap& configurationMap ); +extern bool addPasswordCheck( const QString& key, const QVariant& value, PasswordCheckList& passwordChecks ); +extern QString +makeHostnameSuggestion( const QString& templateString, const QStringList& fullNameParts, const QString& loginName ); + +/** @brief Test Config object methods and internals + * + */ +class UserTests : public QObject +{ + Q_OBJECT +public: + UserTests(); + ~UserTests() override {} + +private Q_SLOTS: + void initTestCase(); + + // Derpy test for getting and setting regular values + void testGetSet(); + + void testDefaultGroups(); + void testDefaultGroupsYAML_data(); + void testDefaultGroupsYAML(); + + void testHostActions_data(); + void testHostActions(); + void testHostActions2(); + void testHostSuggestions_data(); + void testHostSuggestions(); + + void testPasswordChecks(); + void testUserPassword(); + + void testAutoLogin_data(); + void testAutoLogin(); + + void testUserYAML_data(); + void testUserYAML(); + void testUserUmask_data(); + void testUserUmask(); +}; + +UserTests::UserTests() {} + +void +UserTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "Users test started."; + + if ( !Calamares::JobQueue::instance() ) + { + (void)new Calamares::JobQueue(); + } +} + +void +UserTests::testGetSet() +{ + Config c; + + { + const QString sh( "/bin/sh" ); + QCOMPARE( c.userShell(), QString() ); + c.setUserShell( sh ); + QCOMPARE( c.userShell(), sh ); + c.setUserShell( sh + sh ); + QCOMPARE( c.userShell(), sh + sh ); + + const QString badsh( "bash" ); // Not absolute, that's bad + c.setUserShell( badsh ); // .. so unchanged + QCOMPARE( c.userShell(), sh + sh ); // what was set previously + + // Explicit set to empty is ok + c.setUserShell( QString() ); + QCOMPARE( c.userShell(), QString() ); + } + { + const QString al( "autolg" ); + QCOMPARE( c.autoLoginGroup(), QString() ); + c.setAutoLoginGroup( al ); + QCOMPARE( c.autoLoginGroup(), al ); + QVERIFY( !c.doAutoLogin() ); + c.setAutoLogin( true ); + QVERIFY( c.doAutoLogin() ); + QCOMPARE( c.autoLoginGroup(), al ); + } + { + const QString su( "sudogrp" ); + QCOMPARE( c.sudoersGroup(), QString() ); + c.setSudoersGroup( su ); + QCOMPARE( c.sudoersGroup(), su ); + } + { + const QString ful( "Jan-Jaap Karel Kees" ); + const QString lg( "jjkk" ); + QCOMPARE( c.fullName(), QString() ); + QCOMPARE( c.loginName(), QString() ); + QVERIFY( c.loginNameStatus().isEmpty() ); // empty login name is ok + c.setLoginName( lg ); + c.setFullName( ful ); + QVERIFY( c.loginNameStatus().isEmpty() ); // now it's still ok + QCOMPARE( c.loginName(), lg ); + QCOMPARE( c.fullName(), ful ); + } + // Test forbidden login names + { + QVERIFY( c.forbiddenLoginNames().contains( QStringLiteral( "root" ) ) ); + QVERIFY( c.loginNameStatus().isEmpty() ); // it's ok now + c.setLoginName( "root" ); + QVERIFY( !c.loginNameStatus().isEmpty() ); // can't be root + } +} + +void +UserTests::testDefaultGroups() +{ + { + QList< GroupDescription > groups; + QVariantMap hweelGroup; + QVERIFY( groups.isEmpty() ); + hweelGroup.insert( "defaultGroups", QStringList { "hweel" } ); + setConfigurationDefaultGroups( hweelGroup, groups ); + QCOMPARE( groups.count(), 1 ); + QVERIFY( groups.contains( GroupDescription( "hweel" ) ) ); + } + + { + QStringList desired { "wheel", "root", "operator" }; + QList< GroupDescription > groups; + QVariantMap threeGroup; + QVERIFY( groups.isEmpty() ); + threeGroup.insert( "defaultGroups", desired ); + setConfigurationDefaultGroups( threeGroup, groups ); + QCOMPARE( groups.count(), 3 ); + QVERIFY( !groups.contains( GroupDescription( "hweel" ) ) ); + for ( const auto& s : desired ) + { + QVERIFY( groups.contains( GroupDescription( s ) ) ); + } + } + + { + QList< GroupDescription > groups; + QVariantMap explicitEmpty; + QVERIFY( groups.isEmpty() ); + explicitEmpty.insert( "defaultGroups", QStringList() ); + setConfigurationDefaultGroups( explicitEmpty, groups ); + QCOMPARE( groups.count(), 0 ); + } + + { + QList< GroupDescription > groups; + QVariantMap missing; + QVERIFY( groups.isEmpty() ); + setConfigurationDefaultGroups( missing, groups ); + QCOMPARE( groups.count(), 6 ); // because of fallback! + QVERIFY( groups.contains( GroupDescription( "lp", false, GroupDescription::SystemGroup {} ) ) ); + } + + { + QList< GroupDescription > groups; + QVariantMap typeMismatch; + QVERIFY( groups.isEmpty() ); + typeMismatch.insert( "defaultGroups", 1 ); + setConfigurationDefaultGroups( typeMismatch, groups ); + QCOMPARE( groups.count(), 6 ); // because of fallback! + QVERIFY( groups.contains( GroupDescription( "lp", false, GroupDescription::SystemGroup {} ) ) ); + } +} + +void +UserTests::testDefaultGroupsYAML_data() +{ + QTest::addColumn< QString >( "filename" ); + QTest::addColumn< int >( "count" ); + QTest::addColumn< QString >( "group" ); + + QTest::newRow( "users.conf" ) << "users.conf" << 8 << "video"; + QTest::newRow( "dashed list" ) << "tests/4-audio.conf" << 4 << "audio"; + QTest::newRow( "blocked list" ) << "tests/3-wing.conf" << 3 << "wing"; + QTest::newRow( "issue 1523" ) << "tests/5-issue-1523.conf" << 4 << "foobar"; +} + +void +UserTests::testDefaultGroupsYAML() +{ + if ( !Calamares::JobQueue::instance() ) + { + (void)new Calamares::JobQueue(); + } + + QFETCH( QString, filename ); + QFETCH( int, count ); + QFETCH( QString, group ); + + // BUILD_AS_TEST is the source-directory path + QFileInfo fi( QString( "%1/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + QVERIFY( map.count() > 0 ); + + Config c; + c.setConfigurationMap( map ); + + QCOMPARE( c.defaultGroups().count(), count ); + QVERIFY( c.defaultGroups().contains( group ) ); +} + +void +UserTests::testHostActions_data() +{ + QTest::addColumn< bool >( "set" ); + QTest::addColumn< QString >( "string" ); + QTest::addColumn< int >( "result" ); + + QTest::newRow( "unset " ) << false << QString() << int( HostNameAction::EtcHostname ); + QTest::newRow( "empty " ) << true << QString() << int( HostNameAction::EtcHostname ); + QTest::newRow( "bad " ) << true << QString( "derp" ) << int( HostNameAction::EtcHostname ); + QTest::newRow( "none " ) << true << QString( "none" ) << int( HostNameAction::None ); + QTest::newRow( "systemd" ) << true << QString( "Hostnamed" ) << int( HostNameAction::SystemdHostname ); + QTest::newRow( "etc(1) " ) << true << QString( "etcfile" ) << int( HostNameAction::EtcHostname ); + QTest::newRow( "etc(2) " ) << true << QString( "etc" ) << int( HostNameAction::EtcHostname ); + QTest::newRow( "etc-bad" ) + << true << QString( "etchost" ) + << int( HostNameAction::EtcHostname ); // This isn't a valid name, but defaults to EtcHostname + QTest::newRow( "ci-sysd" ) << true << QString( "hOsTnaMed" ) + << int( HostNameAction::SystemdHostname ); // Case-insensitive + QTest::newRow( "trbs " ) << true << QString( "transient" ) << int( HostNameAction::Transient ); + QTest::newRow( "ci-trns" ) << true << QString( "trANSient" ) << int( HostNameAction::Transient ); +} + +void +UserTests::testHostActions() +{ + QFETCH( bool, set ); + QFETCH( QString, string ); + QFETCH( int, result ); + + QVariantMap m; + if ( set ) + { + m.insert( "location", string ); + } + // action is independent of writeHostsFile + QCOMPARE( getHostNameAction( m ), HostNameAction( result ) ); + m.insert( "writeHostsFile", false ); + QCOMPARE( getHostNameAction( m ), HostNameAction( result ) ); + m.insert( "writeHostsFile", true ); + QCOMPARE( getHostNameAction( m ), HostNameAction( result ) ); +} + +void +UserTests::testHostActions2() +{ + Config c; + QVariantMap legacy; + + // Test defaults + c.setConfigurationMap( legacy ); + QCOMPARE( c.hostnameAction(), HostNameAction::EtcHostname ); + QCOMPARE( c.writeEtcHosts(), true ); + + QVariantMap hostSettings; + hostSettings.insert( "writeHostsFile", false ); + hostSettings.insert( "location", "Hostnamed" ); + legacy.insert( "hostname", hostSettings ); + c.setConfigurationMap( legacy ); + QCOMPARE( c.hostnameAction(), HostNameAction::SystemdHostname ); + QCOMPARE( c.writeEtcHosts(), false ); +} + +void +UserTests::testHostSuggestions_data() +{ + QTest::addColumn< QString >( "templateString" ); + QTest::addColumn< QString >( "result" ); + + QTest::newRow( "unset " ) << QString() << QString(); + QTest::newRow( "const " ) << QStringLiteral( "derp" ) << QStringLiteral( "derp" ); + QTest::newRow( "escaped" ) << QStringLiteral( "$$" ) << QString(); // Because invalid + QTest::newRow( "default" ) << QStringLiteral( "${first}-pc" ) + << QStringLiteral( "chuck-pc" ); // Avoid ${product} because it's DMI-based + QTest::newRow( "full " ) << QStringLiteral( "${name}" ) << QStringLiteral( "chuckyeager" ); + QTest::newRow( "login+ " ) << QStringLiteral( "${login}-${first}" ) << QStringLiteral( "bill-chuck" ); + // This is a bit dodgy: assumes CPU architecture of the testing host + QTest::newRow( " cpu " ) << QStringLiteral( "${cpu}X" ) + << QStringLiteral( "x8664X" ); // Assume we don't test on non-amd64 + // These have X X in the template to indicate that they are bogus. Mostly we want + // to see what the template engine does for these. + QTest::newRow( "@prod " ) << QStringLiteral( "X${product}X" ) << QString(); + QTest::newRow( "@prod2 " ) << QStringLiteral( "X${product2}X" ) << QString(); + QTest::newRow( "@host " ) << QStringLiteral( "X${host}X" ) << QString(); +} + +void +UserTests::testHostSuggestions() +{ + const QStringList fullName { "Chuck", "Yeager" }; + const QString login { "bill" }; + + QFETCH( QString, templateString ); + QFETCH( QString, result ); + + if ( templateString.startsWith( 'X' ) && templateString.endsWith( 'X' ) ) + { + QEXPECT_FAIL( "", "Test is too host-specific", Continue ); + cWarning() << Logger::SubEntry << "Next test" << templateString << "->" + << makeHostnameSuggestion( templateString, fullName, login ); + } + QCOMPARE( makeHostnameSuggestion( templateString, fullName, login ), result ); +} + +void +UserTests::testPasswordChecks() +{ + { + PasswordCheckList l; + QCOMPARE( l.length(), 0 ); + QVERIFY( !addPasswordCheck( "nonempty", QVariant( false ), l ) ); // legacy option, now ignored + QCOMPARE( l.length(), 0 ); + QVERIFY( !addPasswordCheck( "nonempty", QVariant( true ), l ) ); // still ignored + QCOMPARE( l.length(), 0 ); + } +} + +void +UserTests::testUserPassword() +{ + if ( !Calamares::JobQueue::instance() ) + { + (void)new Calamares::JobQueue( nullptr ); + } + + { + Config c; + + QVERIFY( c.userPassword().isEmpty() ); + QVERIFY( c.userPasswordSecondary().isEmpty() ); + // There are no validity checks, so no check for nonempty + QCOMPARE( c.userPasswordValidity(), Config::PasswordValidity::Valid ); + + c.setUserPassword( "bogus" ); + QCOMPARE( c.userPasswordValidity(), Config::PasswordValidity::Invalid ); + QCOMPARE( c.userPassword(), "bogus" ); + c.setUserPasswordSecondary( "bogus" ); + QCOMPARE( c.userPasswordValidity(), Config::PasswordValidity::Valid ); + } + + { + Config c; + + QVariantMap m; + m.insert( "allowWeakPasswords", true ); + m.insert( "allowWeakPasswordsDefault", true ); + m.insert( "defaultGroups", QStringList { "wheel" } ); + + QVariantMap pwreq; + pwreq.insert( "nonempty", true ); + pwreq.insert( "minLength", 6 ); + m.insert( "passwordRequirements", pwreq ); + + c.setConfigurationMap( m ); + + QVERIFY( c.userPassword().isEmpty() ); + QVERIFY( c.userPasswordSecondary().isEmpty() ); + // There is now a nonempty check, but weak passwords are ok + QCOMPARE( c.userPasswordValidity(), int( Config::PasswordValidity::Weak ) ); + + c.setUserPassword( "bogus" ); + QCOMPARE( c.userPasswordValidity(), int( Config::PasswordValidity::Invalid ) ); + c.setUserPasswordSecondary( "bogus" ); + QCOMPARE( c.userPasswordValidity(), int( Config::PasswordValidity::Weak ) ); + + QVERIFY( !c.requireStrongPasswords() ); + c.setRequireStrongPasswords( true ); + QVERIFY( c.requireStrongPasswords() ); + // Now changed requirements make the password invalid + QCOMPARE( c.userPassword(), "bogus" ); + QCOMPARE( c.userPasswordValidity(), int( Config::PasswordValidity::Invalid ) ); + } + + { + Config c; + QVERIFY( c.userPassword().isEmpty() ); + QCOMPARE( c.userPasswordValidity(), Config::PasswordValidity::Valid ); + + QSignalSpy spy_pwChanged( &c, &Config::userPasswordChanged ); + QSignalSpy spy_pwSecondaryChanged( &c, &Config::userPasswordSecondaryChanged ); + QSignalSpy spy_pwStatusChanged( &c, &Config::userPasswordStatusChanged ); + + c.setUserPassword( "bogus" ); + c.setUserPassword( "bogus" ); + QCOMPARE( spy_pwChanged.count(), 1 ); + QCOMPARE( spy_pwStatusChanged.count(), 1 ); + QCOMPARE( c.userPasswordValidity(), Config::PasswordValidity::Invalid ); + c.setUserPassword( "sugob" ); + c.setUserPasswordSecondary( "sugob" ); + QCOMPARE( spy_pwChanged.count(), 2 ); + QCOMPARE( spy_pwSecondaryChanged.count(), 1 ); + QCOMPARE( spy_pwStatusChanged.count(), 3 ); + QCOMPARE( c.userPasswordValidity(), Config::PasswordValidity::Valid ); + } +} + +void +UserTests::testAutoLogin_data() +{ + QTest::addColumn< QString >( "filename" ); + QTest::addColumn< bool >( "autoLoginIsSet" ); + QTest::addColumn< QString >( "autoLoginGroupName" ); + + QTest::newRow( "old, old" ) << "tests/6a-issue-1672.conf" << true << "derp"; + QTest::newRow( "old, new" ) << "tests/6b-issue-1672.conf" << true << "derp"; + QTest::newRow( "new, old" ) << "tests/6c-issue-1672.conf" << true << "derp"; + QTest::newRow( "new, new" ) << "tests/6d-issue-1672.conf" << true << "derp"; + QTest::newRow( "default" ) << "tests/6e-issue-1672.conf" << false << QString(); +} + +void +UserTests::testAutoLogin() +{ + QFETCH( QString, filename ); + QFETCH( bool, autoLoginIsSet ); + QFETCH( QString, autoLoginGroupName ); + + // BUILD_AS_TEST is the source-directory path + QFileInfo fi( QString( "%1/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + QVERIFY( map.count() > 0 ); + + Config c; + c.setConfigurationMap( map ); + + QCOMPARE( c.doAutoLogin(), autoLoginIsSet ); + QCOMPARE( c.autoLoginGroup(), autoLoginGroupName ); +} + +void +UserTests::testUserYAML_data() +{ + QTest::addColumn< QString >( "filename" ); + QTest::addColumn< QString >( "shell" ); + + const QString bash = QStringLiteral( "/bin/bash" ); + + // All the old settings are ignored + QTest::newRow( "old, unset " ) << "tests/7ao-shell.conf" << bash; + QTest::newRow( "old, empty " ) << "tests/7bo-shell.conf" << bash; + QTest::newRow( "old, relative" ) << "tests/7co-shell.conf" << bash; + QTest::newRow( "old, invalid " ) << "tests/7do-shell.conf" << bash; + QTest::newRow( "old, absolute" ) << "tests/7eo-shell.conf" << bash; + + QTest::newRow( "new, unset " ) << "tests/7an-shell.conf" + << "/bin/bash"; + QTest::newRow( "new, empty " ) << "tests/7bn-shell.conf" + << ""; + QTest::newRow( "new, relative" ) << "tests/7cn-shell.conf" + << "/bin/ls"; // Setting is ignored + QTest::newRow( "new, invalid " ) << "tests/7dn-shell.conf" + << ""; + QTest::newRow( "new, absolute" ) << "tests/7en-shell.conf" + << "/usr/bin/dash"; +} + +void +UserTests::testUserYAML() +{ + Config c; + c.setUserShell( QStringLiteral( "/bin/ls" ) ); + + QFETCH( QString, filename ); + QFETCH( QString, shell ); + + // BUILD_AS_TEST is the source-directory path + QFileInfo fi( QString( "%1/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + QVERIFY( map.count() > 0 ); + + QCOMPARE( c.userShell(), QStringLiteral( "/bin/ls" ) ); + c.setConfigurationMap( map ); + QCOMPARE( c.userShell(), shell ); +} + +void +UserTests::testUserUmask_data() +{ + QTest::addColumn< QString >( "filename" ); + QTest::addColumn< int >( "permission" ); + QTest::addColumn< int >( "umask" ); + QTest::addColumn< QString >( "umask_string" ); + + QTest::newRow( "good " ) << "tests/8a-issue-2362.conf" << 0700 << 0077 << QStringLiteral( "077" ); + QTest::newRow( "open " ) << "tests/8b-issue-2362.conf" << 0755 << 0022 << QStringLiteral( "022" ); + QTest::newRow( "weird" ) << "tests/8c-issue-2362.conf" << 0126 << 0651 << QStringLiteral( "651" ); + QTest::newRow( "rwxx " ) << "tests/8d-issue-2362.conf" << 0710 << 0067 << QStringLiteral( "067" ); + QTest::newRow( "-wrd " ) << "tests/8e-issue-2362.conf" << 0214 << 0563 << QStringLiteral( "563" ); + QTest::newRow( "bogus" ) << "tests/8f-issue-2362.conf" << -1 << -1 + << QStringLiteral( "-01" ); // Bogus 3-character representation + QTest::newRow( "good2" ) << "tests/8g-issue-2362.conf" << 0750 << 0027 << QStringLiteral( "027" ); + QTest::newRow( "numrc" ) << "tests/8h-issue-2362.conf" << 0751 << 0026 << QStringLiteral( "026" ); +} + +void +UserTests::testUserUmask() +{ + static constexpr int no_permissions = -1; + const QString old_shell = QStringLiteral( "/bin/ls" ); + const QString new_shell = QStringLiteral( "/usr/bin/new" ); + // nobody and root are always forbidden, even if not mentioned in the config, entries are alphabetical + const QStringList forbidden { QStringLiteral( "me" ), + QStringLiteral( "moi" ), + QStringLiteral( "myself" ), + QStringLiteral( "nobody" ), + QStringLiteral( "root" ) }; + Config c; + c.setUserShell( old_shell ); + QCOMPARE( c.homePermissions(), no_permissions ); + QCOMPARE( c.homeUMask(), no_permissions ); + + QFETCH( QString, filename ); + QFETCH( int, permission ); + QFETCH( int, umask ); + QFETCH( QString, umask_string ); + + // Checks that the test-data is valid + if ( permission != -1 ) + { + QCOMPARE( permission & umask, 0 ); + QCOMPARE( permission | umask, 0777 ); + } + + QFileInfo fi( QString( "%1/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + QVERIFY( map.count() > 0 ); + + QCOMPARE( c.userShell(), old_shell ); + c.setConfigurationMap( map ); + QCOMPARE( c.userShell(), new_shell ); + + QCOMPARE( c.homePermissions(), permission ); + QCOMPARE( c.homeUMask(), umask ); + // The QChar() is needed to disambiguate from the overload that takes a double + QCOMPARE( QStringLiteral( "%1" ).arg( umask, 3, 8, QChar( '0' ) ), umask_string ); + + QCOMPARE( c.forbiddenLoginNames(), forbidden ); +} + + +QTEST_GUILESS_MAIN( UserTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/users/UsersPage.cpp b/calamares/src/modules/users/UsersPage.cpp new file mode 100644 index 0000000..e602b87 --- /dev/null +++ b/calamares/src/modules/users/UsersPage.cpp @@ -0,0 +1,313 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-FileCopyrightText: 2020 Gabriel Craciunescu + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Portions from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "UsersPage.h" + +#include "Config.h" +#include "ui_page_usersetup.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "compat/CheckBox.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/String.h" + +#include +#include +#include +#include + +/** @brief Add an error message and pixmap to a label. */ +static inline void +labelError( QLabel* pix, QLabel* label, Calamares::ImageType icon, const QString& message ) +{ + label->setText( message ); + pix->setPixmap( Calamares::defaultPixmap( icon, Calamares::Original, label->size() ) ); +} + +/** @brief Clear error, set happy pixmap on a label to indicate "ok". */ +static inline void +labelOk( QLabel* pix, QLabel* label ) +{ + label->clear(); + pix->setPixmap( Calamares::defaultPixmap( Calamares::StatusOk, Calamares::Original, label->size() ) ); +} + +/** @brief Sets error or ok on a label depending on @p status and @p value + * + * - An **empty** @p value gets no message and no icon. + * - A non-empty @p value, with an **empty** @p status gets an "ok". + * - A non-empty @p value with a non-empty @p status gets an error indicator. + */ +static inline void +labelStatus( QLabel* pix, QLabel* label, const QString& value, const QString& status ) +{ + if ( status.isEmpty() ) + { + if ( value.isEmpty() ) + { + // This is different from labelOK() because no checkmark is shown + label->clear(); + pix->clear(); + } + else + { + labelOk( pix, label ); + } + } + else + { + labelError( pix, label, Calamares::ImageType::StatusError, status ); + } +} + +UsersPage::UsersPage( Config* config, QWidget* parent ) + : QWidget( parent ) + , ui( new Ui::Page_UserSetup ) + , m_config( config ) +{ + ui->setupUi( this ); + + // Connect signals and slots + ui->textBoxUserPassword->setText( config->userPassword() ); + connect( ui->textBoxUserPassword, &QLineEdit::textChanged, config, &Config::setUserPassword ); + connect( config, &Config::userPasswordChanged, ui->textBoxUserPassword, &QLineEdit::setText ); + ui->textBoxUserVerifiedPassword->setText( config->userPasswordSecondary() ); + connect( ui->textBoxUserVerifiedPassword, &QLineEdit::textChanged, config, &Config::setUserPasswordSecondary ); + connect( config, &Config::userPasswordSecondaryChanged, ui->textBoxUserVerifiedPassword, &QLineEdit::setText ); + connect( config, &Config::userPasswordStatusChanged, this, &UsersPage::reportUserPasswordStatus ); + + ui->textBoxRootPassword->setText( config->rootPassword() ); + connect( ui->textBoxRootPassword, &QLineEdit::textChanged, config, &Config::setRootPassword ); + connect( config, &Config::rootPasswordChanged, ui->textBoxRootPassword, &QLineEdit::setText ); + ui->textBoxVerifiedRootPassword->setText( config->rootPasswordSecondary() ); + connect( ui->textBoxVerifiedRootPassword, &QLineEdit::textChanged, config, &Config::setRootPasswordSecondary ); + connect( config, &Config::rootPasswordSecondaryChanged, ui->textBoxVerifiedRootPassword, &QLineEdit::setText ); + connect( config, &Config::rootPasswordStatusChanged, this, &UsersPage::reportRootPasswordStatus ); + + ui->textBoxFullName->setText( config->fullName() ); + connect( ui->textBoxFullName, &QLineEdit::textEdited, config, &Config::setFullName ); + connect( config, &Config::fullNameChanged, this, &UsersPage::onFullNameTextEdited ); + + // If the hostname is going to be written out, then show the field + if ( ( m_config->hostnameAction() == HostNameAction::EtcHostname ) + || ( m_config->hostnameAction() == HostNameAction::SystemdHostname ) ) + { + ui->textBoxHostname->setText( config->hostname() ); + connect( ui->textBoxHostname, &QLineEdit::textEdited, config, &Config::setHostName ); + connect( config, + &Config::hostnameChanged, + [ this ]( const QString& name ) + { + if ( !ui->textBoxHostname->hasFocus() ) + { + ui->textBoxHostname->setText( name ); + } + } ); + connect( config, &Config::hostnameStatusChanged, this, &UsersPage::reportHostNameStatus ); + } + else + { + // Need to hide the hostname parts individually because there's no widget-group + ui->hostnameLabel->hide(); + ui->labelHostname->hide(); + ui->textBoxHostname->hide(); + ui->labelHostnameError->hide(); + } + + ui->textBoxLoginName->setText( config->loginName() ); + connect( ui->textBoxLoginName, &QLineEdit::textEdited, config, &Config::setLoginName ); + connect( config, &Config::loginNameChanged, ui->textBoxLoginName, &QLineEdit::setText ); + connect( config, &Config::loginNameStatusChanged, this, &UsersPage::reportLoginNameStatus ); + + ui->checkBoxDoAutoLogin->setVisible( m_config->displayAutoLogin() ); + ui->checkBoxDoAutoLogin->setChecked( m_config->doAutoLogin() ); + connect( ui->checkBoxDoAutoLogin, + Calamares::checkBoxStateChangedSignal, + this, + [ this ]( Calamares::checkBoxStateType checked ) + { m_config->setAutoLogin( checked != Calamares::checkBoxUncheckedValue ); } ); + connect( config, &Config::autoLoginChanged, ui->checkBoxDoAutoLogin, &QCheckBox::setChecked ); + + ui->checkBoxReusePassword->setVisible( m_config->writeRootPassword() ); + ui->checkBoxReusePassword->setChecked( m_config->reuseUserPasswordForRoot() ); + if ( m_config->writeRootPassword() ) + { + connect( config, &Config::reuseUserPasswordForRootChanged, ui->checkBoxReusePassword, &QCheckBox::setChecked ); + connect( ui->checkBoxReusePassword, Calamares::checkBoxStateChangedSignal, this, &UsersPage::onReuseUserPasswordChanged ); + } + + ui->checkBoxRequireStrongPassword->setVisible( m_config->permitWeakPasswords() ); + ui->checkBoxRequireStrongPassword->setChecked( m_config->requireStrongPasswords() ); + if ( m_config->permitWeakPasswords() ) + { + connect( ui->checkBoxRequireStrongPassword, + Calamares::checkBoxStateChangedSignal, + this, + [ this ]( int checked ) { m_config->setRequireStrongPasswords( checked != Qt::Unchecked ); } ); + connect( + config, &Config::requireStrongPasswordsChanged, ui->checkBoxRequireStrongPassword, &QCheckBox::setChecked ); + } + + // Active Directory is not checked or enabled by default + ui->useADCheckbox->setVisible( m_config->getActiveDirectoryEnabled() ); + onActiveDirectoryToggled( false ); + + connect( ui->useADCheckbox, &QCheckBox::toggled, this, &UsersPage::onActiveDirectoryToggled ); + connect( ui->domainField, &QLineEdit::textChanged, config, &Config::setActiveDirectoryDomain ); + connect( ui->domainAdminField, &QLineEdit::textChanged, config, &Config::setActiveDirectoryAdminUsername ); + connect( ui->domainPasswordField, &QLineEdit::textChanged, config, &Config::setActiveDirectoryAdminPassword ); + connect( ui->ipAddressField, &QLineEdit::textChanged, config, &Config::setActiveDirectoryIP ); + + CALAMARES_RETRANSLATE_SLOT( &UsersPage::retranslate ); + + onReuseUserPasswordChanged( m_config->reuseUserPasswordForRoot() ); + onFullNameTextEdited( m_config->fullName() ); + reportLoginNameStatus( m_config->loginNameStatus() ); + reportHostNameStatus( m_config->hostnameStatus() ); + + ui->textBoxLoginName->setEnabled( m_config->isEditable( "loginName" ) ); + ui->textBoxFullName->setEnabled( m_config->isEditable( "fullName" ) ); + + retranslate(); +} + +UsersPage::~UsersPage() +{ + delete ui; +} + +void +UsersPage::retranslate() +{ + ui->retranslateUi( this ); + if ( Calamares::Settings::instance()->isSetupMode() ) + { + ui->textBoxLoginName->setToolTip( tr( "If more than one person will " + "use this computer, you can create multiple " + "accounts after setup." ) ); + } + else + { + ui->textBoxLoginName->setToolTip( tr( "If more than one person will " + "use this computer, you can create multiple " + "accounts after installation." ) ); + } + + const auto up = m_config->userPasswordStatus(); + reportUserPasswordStatus( up.first, up.second ); + const auto rp = m_config->rootPasswordStatus(); + reportRootPasswordStatus( rp.first, rp.second ); +} + +void +UsersPage::onActivate() +{ + ui->textBoxFullName->setFocus(); + const auto up = m_config->userPasswordStatus(); + reportUserPasswordStatus( up.first, up.second ); + const auto rp = m_config->rootPasswordStatus(); + reportRootPasswordStatus( rp.first, rp.second ); +} + +void +UsersPage::onFullNameTextEdited( const QString& fullName ) +{ + labelStatus( ui->labelFullName, ui->labelFullNameError, fullName, QString() ); +} + +void +UsersPage::reportLoginNameStatus( const QString& status ) +{ + labelStatus( ui->labelUsername, ui->labelUsernameError, m_config->loginName(), status ); +} + +void +UsersPage::reportHostNameStatus( const QString& status ) +{ + labelStatus( ui->labelHostname, ui->labelHostnameError, m_config->hostname(), status ); +} + +static inline void +passwordStatus( QLabel* iconLabel, QLabel* messageLabel, int validity, const QString& message ) +{ + switch ( validity ) + { + case Config::PasswordValidity::Valid: + labelOk( iconLabel, messageLabel ); + break; + case Config::PasswordValidity::Weak: + labelError( iconLabel, messageLabel, Calamares::StatusWarning, message ); + break; + case Config::PasswordValidity::Invalid: + default: + labelError( iconLabel, messageLabel, Calamares::StatusError, message ); + break; + } +} + +void +UsersPage::reportRootPasswordStatus( int validity, const QString& message ) +{ + passwordStatus( ui->labelRootPassword, ui->labelRootPasswordError, validity, message ); +} + +void +UsersPage::reportUserPasswordStatus( int validity, const QString& message ) +{ + passwordStatus( ui->labelUserPassword, ui->labelUserPasswordError, validity, message ); +} + +void +UsersPage::onReuseUserPasswordChanged( const int checked ) +{ + // Pass the change on to config + m_config->setReuseUserPasswordForRoot( checked != Qt::Unchecked ); + /* When "reuse" is checked, hide the fields for explicitly + * entering the root password. However, if we're going to + * disable the root password anyway, hide them all regardless of + * the checkbox -- so when writeRoot is false, visible needs + * to be false, to hide them all. + * + * In principle this is only connected when writeRootPassword is @c true, + * but it is **always** called at least once in the constructor + * to set up initial visibility. + */ + const bool visible = m_config->writeRootPassword() ? !checked : false; + ui->labelChooseRootPassword->setVisible( visible ); + ui->labelRootPassword->setVisible( visible ); + ui->labelRootPasswordError->setVisible( visible ); + ui->textBoxRootPassword->setVisible( visible ); + ui->textBoxVerifiedRootPassword->setVisible( visible ); +} + +void +UsersPage::onActiveDirectoryToggled( bool checked ) +{ + ui->domainLabel->setVisible( checked ); + ui->domainField->setVisible( checked ); + ui->domainAdminLabel->setVisible( checked ); + ui->domainAdminField->setVisible( checked ); + ui->domainPasswordField->setVisible( checked ); + ui->domainPasswordLabel->setVisible( checked ); + ui->ipAddressField->setVisible( checked ); + ui->ipAddressLabel->setVisible( checked ); + + m_config->setActiveDirectoryUsed( checked ); +} diff --git a/calamares/src/modules/users/UsersPage.h b/calamares/src/modules/users/UsersPage.h new file mode 100644 index 0000000..379176a --- /dev/null +++ b/calamares/src/modules/users/UsersPage.h @@ -0,0 +1,56 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2007 Free Software Foundation, Inc. + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Portions from the Manjaro Installation Framework + * by Roland Singer + * Copyright (C) 2007 Free Software Foundation, Inc. + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef USERSPAGE_H +#define USERSPAGE_H + +#include + +class Config; + +class QLabel; + +namespace Ui +{ +class Page_UserSetup; +} // namespace Ui + +class UsersPage : public QWidget +{ + Q_OBJECT +public: + explicit UsersPage( Config* config, QWidget* parent = nullptr ); + ~UsersPage() override; + + void onActivate(); + +protected slots: + void onFullNameTextEdited( const QString& ); + void reportLoginNameStatus( const QString& ); + void reportHostNameStatus( const QString& ); + void onReuseUserPasswordChanged( const int ); + void reportUserPasswordStatus( int, const QString& ); + void reportRootPasswordStatus( int, const QString& ); + + void onActiveDirectoryToggled( bool checked ); + +private: + void retranslate(); + + Ui::Page_UserSetup* ui; + Config* m_config; +}; + +#endif // USERSPAGE_H diff --git a/calamares/src/modules/users/UsersViewStep.cpp b/calamares/src/modules/users/UsersViewStep.cpp new file mode 100644 index 0000000..6836734 --- /dev/null +++ b/calamares/src/modules/users/UsersViewStep.cpp @@ -0,0 +1,119 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2017 Gabriel Craciunescu + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "UsersViewStep.h" + +#include "Config.h" +#include "UsersPage.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Variant.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( UsersViewStepFactory, registerPlugin< UsersViewStep >(); ) + +UsersViewStep::UsersViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_widget( nullptr ) + , m_config( new Config( this ) ) +{ + connect( m_config, &Config::readyChanged, this, &UsersViewStep::nextStatusChanged ); + + emit nextStatusChanged( m_config->isReady() ); +} + + +UsersViewStep::~UsersViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + + +QString +UsersViewStep::prettyName() const +{ + return tr( "Users" ); +} + + +QWidget* +UsersViewStep::widget() +{ + if ( !m_widget ) + { + m_widget = new UsersPage( m_config ); + } + return m_widget; +} + + +bool +UsersViewStep::isNextEnabled() const +{ + return m_config->isReady(); +} + + +bool +UsersViewStep::isBackEnabled() const +{ + return true; +} + + +bool +UsersViewStep::isAtBeginning() const +{ + return true; +} + + +bool +UsersViewStep::isAtEnd() const +{ + return true; +} + + +Calamares::JobList +UsersViewStep::jobs() const +{ + return m_config->createJobs(); +} + + +void +UsersViewStep::onActivate() +{ + if ( m_widget ) + { + m_widget->onActivate(); + } +} + + +void +UsersViewStep::onLeave() +{ + m_config->finalizeGlobalStorage(); +} + + +void +UsersViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); +} diff --git a/calamares/src/modules/users/UsersViewStep.h b/calamares/src/modules/users/UsersViewStep.h new file mode 100644 index 0000000..8d5abe4 --- /dev/null +++ b/calamares/src/modules/users/UsersViewStep.h @@ -0,0 +1,56 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef USERSPAGEPLUGIN_H +#define USERSPAGEPLUGIN_H + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include +#include + +class Config; +class UsersPage; + +class PLUGINDLLEXPORT UsersViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit UsersViewStep( QObject* parent = nullptr ); + ~UsersViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onActivate() override; + void onLeave() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + UsersPage* m_widget; + Config* m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( UsersViewStepFactory ) + +#endif // USERSPAGEPLUGIN_H diff --git a/calamares/src/modules/users/images/invalid.png b/calamares/src/modules/users/images/invalid.png new file mode 100644 index 0000000000000000000000000000000000000000..2cb032501794f419f9f2a052d9d4218c91b289fc GIT binary patch literal 7822 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 +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/users/images/valid.png b/calamares/src/modules/users/images/valid.png new file mode 100644 index 0000000000000000000000000000000000000000..3ccfc5596894dc3b535de01ce6422064d0b144e7 GIT binary patch literal 7284 zcmV-)9E;WFU8GbZ8()Nlj2>E@cM*02|mzL_t(|+U=crv|Ux1 z;D39c>CSJ;JSRYAP{1l6?O0{k5-b%>1P7exMYUamI92QFYD>@(k);F>Y{jms#$XvJ z3|Ou%nN-?B!5|JmfDB}w^QQambcQ|jANM6ZUcKSomzUv%?5w@+%U$>EbI$(lZ`j}7 z=R0t^oGz!!>2kW9E~m@s(qIv%%8gued%94RiC0JmLAj`^WgA8JeVW>7NXN1K_5aG} zOu$*lWTx-S`BJ!xq>yJ?VW&$eWZ=uR5IC;mc!^Sx(WqFN$XcpcyGt9p(JH+uG3+*p z+HvPq@m9c|hICYtL7)oR<&MMouH(Eh6}T7V^Zv9<%FneFQslD%nIJ{l4-g1k*Fyk~ zlxU;T#$vQWSxqgDDAj6|N(p_X(1uamS?r6pDr0{c$NF(=)idAuNVNSlqz1~W`*1o+ z*>gQlu1u%hx3w06g_AmRnaS-rCgs}5r!oXi3Qu@QTpV0Xzc5%V7LCQeEFb-MXEA82 zF+!2(h?I-s=DT*40F>DoEG3dtDXQkOc}FKM0(aD;;unrc#EZ%-$iwjShAcjfV@YHqpx zLw)NI$KTmFu8^+39=G$%?B$t^_qPk?wl6tz)(mDBW|0%E#3rU!bx}2CbiWD{CtwUo zB)$yrWPlKss;;oFr;``9>^BDvRvrkW&mb#W=u8gLopfTXJ}Kc0g-Z&T zluY4DNzH_8-M63TU)*=FT!}vOtq)dyFd-pbeOJrbL6$quT+ngh@_F-FkX?W_nqARO zLQ_MY6l$&c;tQWZrqDvMzps;LHtg12-L+fW3ho!b^!D!Rgn@MRU4=_?1^3&_mrkF) zeCC-<_h-@-cX1%zhp}dYNYC&p(7_??q!AMOqCPfk-ObjQdVcCk_ru?OUw7w859yP4 z7d}*I@$P)h@|mgSQy0-D+u0rMqC4qDic=<@i$D;_6uxvR>k6B8>}BJo&c~wAy#L!D zDeWK6b2;PcAp9Wn;nueFo#&lBJ9W;~B@|?yE!CIkQ+Vdd?(n->_|D3s^P{@AoymMx!UOQz89s!%nTZl}I5Ry~1SPOyl zNjW~D4cWYFA1}Ok@G)tf_l}q98y88sW=-a;g_hJ^uRVKq_S~sU@$q?~w3)~z6WabX zT9nmjYe_pP^7#}->UoE{%jdk|($oXLUKb@N9MU!aC%rUN2-YoMHfz#((-spD@O)`A zu~qdTX1}pLl`DWkW2_-9Q{)Oh%Eo7Q_eRssd0X_;r+QCx5^EPPnY#46 zSqsU_92-hoNX)AT&?`V0jj@`HlO|UPsKw#A)f$!c>*8OXFi0!j>#m-=p!L#o=bp** zU@|Y1wh`*MuHs%zga(jORwID4nQWovmiy-5in)i_8pei|<$+^){PKRNGW_ebl0oRp3mq-%eWU6e|B z_byvDEBo3>XKD|MR#tUc^CKwT+iY7+ zPhh&^J*(O~Qg2@{eJbs4j?JaLB-UX1FYI)!R|AA5p{62oUYfb{+oc~kU-|fVohipu z-F@N#x5F3de>`j9lqqLVn#aMg$ib-is`KO;woeuks|ciz@B73y>Znw7@|$(>&ySfT zxz)@krp?GLo7*-SjA3`R3#`Bz`|7B+`QK|}sU#68;V^YlE19hKU$6bA?4o00ldk#a z^sH1MKR$JGD>G6p?5G?fHVP@k#MohY6oFKW#b~hBBBek`F(4*BdI?QLU>pjW3{z(0 zJ9bp{CxB0mJ0yvBWykc~^w}+KL^|P6SjHkSr!pZx#fEAnB1tp?iA9qNd@^~TMnjSo zD8~^kL7I;C0*5lC4_^C&>>am$u(EO7Abt8Dy>`cyS59irGdak!z1)p521m%Fi+7x~ z(o-*SXegCxv;}9de9kJ;UI+V2&+_7-2PpPb$+rebM_}#f?L}4*YfV0tqGNity|Pt* z4EVyhISvAIsLIkv!;`hF5AmJ{l@WB{RdHK=qr|)S-60=FS?!iEpK6RW(o6K z-^}FP61H{yifUNG3*3f!!T`ovvaU}e482{|x#wS!x_kWtVfCmXUH|`ZlWJ=9jJd7z z7PU{|V68+kuAR~zT^wpkr82YfD|q`^w@~nByrNH9Q}bxc%wyA`M}Y*#la0mptRdyN zNLNw`!$PH6`@{Nm$#X~T-SuqGFZa{#1s%B@j)g-}4UkyFsaI`X#q|}dbwG=5A?HsW zvR@XozJ=-e^9iE_V;gJV7^o%*Y3bA2o)xZiuN)VfWPL6vbfnXh(>Z$L8nqVPge>lSj})Di5AL)vX8(F$W4x6HLfk@b8s=@jXV`?^oun_YOvWtB~1hIIY) zxJ5@^lFz5edk)+C`molXlFmXB*8$CLS;0FN-b&6NIWVAYOflSzloDebAF`T+SS4h9 zmt4EwTHBTU88CK8J!fRjN~hfAg>(vIEPZhZ{RZ%qamGR z2&Gb$+4&W`bJ4fR`_o1TbYLw%-*zLeCy<_K`n;i12p|Z2T+ccGvuoQYjaia}CtsgR z`{X^3L)B_M)FDnS396K9%qgtkor`ZJ--rYqSj)p(zJzo&e#QZ7X;!hs+Mtak?Rf-g zZ$_n7TLS!h(~$n^dw5ca3sQlH3l!t1?!}xUE`v^>QVN;dx`KBtzLmT`b<|^if8d8a zy!m>Br|~ngDK+;Bm1iwsqVOF_I_G$$UU}}AAO%5Tj_b>*8Q&u|hDd3wwVVo!bOO~% z#GJxKT)Oy{#sKYK%R`&5L%0e*C6B5vEKpMktw9Rcb8)43-I#2WaK${wb2?IvOQH?2 zHb+(+CvsH@R4Ng(3oCf{(%TxcLBHR>mIt?753a^fORPBh>xi{M3UGZ73(HpBH+*1W zSV@wW`9a2!z9Xq55!z_v1mEOn5<#sRF{fn(?_ScFiu>KZwLG}x??7t&puTf;Jmw&= zwHRaY9EszJDf5k-3}$~*kc4EubR9e?D8-72uIUZ~n%lC1l}m4H4AB0yJh=Hfa5R3J zy3;gHouF;qy1POkrD(0za#NwNNl1dZ!V!2v5GD$1( zW^(@IQIA=__xn7&`K#c>c&Yvv_c*H&V=YQ+90{K9h^jO#O+ylh4o66|F(_k?tl3XE zK(&MgZExn?jRM-UhKII%6(M6BzwRy{i()M(V{rrsMsH-a>WUeVl)A!>t z6)CL68iO`wLQP_=a*0D#2ej_rXaZq(tr2@BfZz{+K-$u|OM_xE{d^9@)h#_=@A<5uf^YU;bKtYxH!h=+t! z$)R$#9_&8s)g1MOTY=TGK6(&H*McS&!|@u6buRs@ z+ZM1QYAWJz=T#`M%Jff;#9`4NYgG<9bn#3JlT%B{yR*=di;cbUTl3Zqsvpp*Wvj_I z;3WKJ_ZlAFd;?ZQNKbQQGI*S-6#bi6j71n-ZPvT9V2fJoms6FEb%+@k9SfFDxtKT4 zzna$6Od?(75Bt{f%k8(JV}tNieIzt{io?ek2Nhf9wO+)^W#1$hOllC&S1}?&c!N@~ zaeUOBCm}WlYb`>`?&e)^OQ+TrV=Rv74^}nn-dP75JDfA^oxEf5SMZ%|J$UQpdE>nQ zM%HWR-!^?2lY|I2X`Yg;pF_tmujL|EEx(0)(0~N(TEin-Zp4TL;p%!r$9WQHE%;JW zO%y6NXlZ(zpUeue*J?|w44#l<)72WR zy68kOxAj6+EniI`7(J4(e)ruxyyXV0h`~utSY%%!aD^aI29?-IE7j9HBxAQ*V~MoJ zmGxP7vw~!_VOn;{h>L0G&$tY*{B+}2&9}zd?QxGSSRL0 z&CZX+?1UvY3TsT!75ZTF>Q0EwI?_bSka3+c-H$*V$?P~%Ie*6GfZ)N6Uq&lN?Lc8Y zjFQZ2y?~X?NYLgRF@#td!-;DnF)4e-D4;F_Yk6?f-=np_$Z&W-S~9og!hS%bFL~E(&`oHf6A!5F%n>+s z$GRT8X6x$9hEI<@>;$UnCEGCeK&&)EfGg?=XPeq>C^Y40KR1k&k8b%oh8l%3;O|$EJ0+*Ai7LQa+~ zofZfjhm_~g7bPU2>9)!|KW0eQ@aw3m<7%QwNjc`ogRu$7gq=N4aQ}0kqptypI)CPS zdE27@i7T>LE$R}q{N@HFs2`B2p$Yp{3@1gk2C}|`BP8Wog0<#_UH6x^jdVXFK2>qf z`$eCxoy(m-w&w$nYGP2vjBy0jZCkOnkA0;VSSY|9qn^T%0?Iwv!nM(9@y}C`l5rQ&bB-!-2k9_d3e(e zB&Le8)%sEZCs`5>YiUb+NGa&4#+bxzXwA!~jv7+68a|#xW?L;v5W*6;5^Ijmwb3TT z5V51@3GRRPa}7z3SKn@6Q9);>N5ynRB?0u34p8Gs~;lVKhdV2RgJi75~#JWOa$|t3Y zJM1vze22EI!`@;@RMlkz4P{4ZXt$#k^6s!=di!b#u9WzW9GAhROoX-(JA0qt{^$Ot zX^Hyn?t6G-^Nl2?N@B|WtDPsM5MiukPFse)&`_+#V0i52D|(+FH%PZ!*7JhV_9sef zDzPRXxZ@UCQ6@s!h#fso^5f^e(6B^3z2{yY-E9X9B4rxzPjmK{e zVyvzwr3eqOzi$)sJ5~_5*&%Guy*#@4Mq<@3K_~exCSxHVxGbNQX4 z`*jtk%EY?#@`Bw-T!n?#%*wN`7}HY;F^PSoT%7k%b4xx=mQSy3xh0)eNvCQsBarVp{zj(2mQYkKL zrT^lV_w_x|Ogl{{B}|06NmhBMfcYt3kP8IGs$o2HvnM)4>4?PC*xvOx+o|5|&B*ZlzB08q24lb5ETCr99RRmqUfF9D|09l!Emk!d-y!AImodh5USTat zRS1(JQPM}O$|zNx5Gt-70yDgJPJwc0*ws}<)y#82l=WqB>XV+NT8&jg(#~#C8czOPp zI?;cQv@={skO?HU$Z(2hRvR6VHB8O>{ON)MPrg*5SdGyU-?;hm-tUapP;I!ZUUjHj z8uQ5{(#5`Ng0N6X%l)<~-6A-5Laa5UhLDtY^_?~8U{ z@b2IcmcL94BA0TIqP_!0h*y^cX#<(S;VnyB+1C@ZVNZ!9G%q5e%f4|%_0S1}1n}gK zqTgTmp1^lJ-WVm2PdOkUPVB1)P+i^s+46R}%bMS9>qEtQuZ_&gn?F$5c(m=0izRMx zi(igv`g_V~x+{uI;E+qZr(~k2|L7zL(t+SD%iAed6i>a>OA@PIW5pFWf2j2P@i@o0 zDW&W0nvp7|Yiom)c$Y-bn)Qg4rLUTt!hoP9tE_cC8mnvW8ZjR@4e%7 zo^iDtpZc-i>O`5_{6Kt2AZg9I7z@4S1a09|dDWGnp2%r>yUzt@<=J&8;+dUgVr8l} zvHxZD`-^MG^L*oO-miIZPT&=b-*7y6wIc-t}BAH=aNfH$~8?X)W`P3$2b<330z)x zW{zyi;kR2#bXQ}nHqW5=t8ZLh{LQgA@3DOu@V^u;6*#xKo|x?lK}*)d7)!CLh$oO# znf|98=T1qpWLAdma>6s)ON5ERXnq{q_^)rjyt4OLo&T73)2Hrfo#zO??FHguVFbC9 zOUf5iBSX2S(dOiZb$Gu`Th3+q+ze^oW#gU-dwXM4Y(k6q+ZH+P_Ajm46d%j$5aW2< zaBZ0@?rzCW@$6@Wke~H@(c&N|WL+E~DAg2|sJ=)0WPx>fPeMNBFn?N_866&bx)QeR ztCARlQ9Owf*M0N-Jr9jAm9Y(eH?EMxfZvND>QL>80S-}o3ln|S^i^+@TM{qi6(v{`bW%Abtq6nwHRL+V0Bs;h z8dZUl=F$4rE7N~IdA|QXKPAp~T@gqj2&5pBa_~I~{mGJ|*b*y^(sg%OY1^L{_LV0Y zm~uu4@En2b2s~Gk_66CrLq6@|yY)N@y_JOSvZ5Snl(DGDbk<7d=UabUyZ4drsb>bj z5u>O!1_3w><#0^~z|zM8k{kfV8&KT=00*l;IfM;kh(1lECo*sNfV=pNbN#pGCb@5N zeKE)P>#suf9f{{kQog`<>(83j^Dc-Z_DlC?60;UuM<9j3b3q7+BP~)$q^K{*RYOBH zGE^d6ccTFnnMzbK&mY>QAKLV*@X0?uVs`dlpEruFVE~kY>P!!Slnkh9fHc`-sqWlC zkOr&3!D>(r<7|jQ*PsAfm`U58zTI0kW1)9$uHAi2kdm{VfHude=aY~ETq$s+KuQou zTnB^@II{kDiKV`LP>-=05^YJeMO#~6P6>@M%I*s*c1!nO_4Kws#D9GHU-ecX95CpH zGyVhB*>w>>N>8*1DO#kof$s}3ly{+&MQf|Arff8QDz;rwMeixKC!q5Rx^VYwipoMT7qqL%8u`1aDCcay$m#@K7(?8{Fn_a~3;9n}eNzff8hg z0X3AXZH74KaS02JG_jSVd=3X^@b`=|X%-{gi)~;IGq~f18tp@UZ$|kXuKg1lyAvaP z4(?z%0^EVW5yM0}XaLYipcrQ6ZwIKskzOcfMgh%ufH~FyRx!f=#VFq$R!5@2c4$zN zMuRr6)5Qo9U>j^_Vnx9lJdYTMuoVqRQ?tLZ +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/users/page_usersetup.ui b/calamares/src/modules/users/page_usersetup.ui new file mode 100644 index 0000000..6e6e542 --- /dev/null +++ b/calamares/src/modules/users/page_usersetup.ui @@ -0,0 +1,710 @@ + + + +SPDX-FileCopyrightText: 2014 Teo Mrnjavac <teo@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + Page_UserSetup + + + + 0 + 0 + 862 + 683 + + + + Form + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 6 + + + + + + + + What is your name? + + + + + + + + + + 200 + 0 + + + + Your Full Name + + + + + + + + 0 + 0 + + + + + 24 + 24 + + + + + 24 + 24 + + + + + + + true + + + + + + + + 1 + 0 + + + + + + + true + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 6 + + + + + + + + What name do you want to use to log in? + + + false + + + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + login + + + + + + + + 0 + 0 + + + + + 24 + 24 + + + + + 24 + 24 + + + + true + + + + + + + + 1 + 0 + + + + + 200 + 0 + + + + + + + Qt::AlignVCenter + + + true + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 6 + + + + + + + + What is the name of this computer? + + + false + + + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + <small>This name will be used if you make the computer visible to others on a network.</small> + + + Computer Name + + + + + + + + 0 + 0 + + + + + 24 + 24 + + + + + 24 + 24 + + + + true + + + + + + + + 1 + 0 + + + + + 200 + 0 + + + + + + + Qt::AlignVCenter + + + true + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 6 + + + + + + + + Choose a password to keep your account safe. + + + false + + + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + <small>Enter the same password twice, so that it can be checked for typing errors. A good password will contain a mixture of letters, numbers and punctuation, should be at least eight characters long, and should be changed at regular intervals.</small> + + + QLineEdit::Password + + + Password + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + <small>Enter the same password twice, so that it can be checked for typing errors. A good password will contain a mixture of letters, numbers and punctuation, should be at least eight characters long, and should be changed at regular intervals.</small> + + + QLineEdit::Password + + + Repeat Password + + + + + + + + 0 + 0 + + + + + 24 + 24 + + + + + 24 + 24 + + + + true + + + + + + + + 1 + 0 + + + + + 100 + 0 + + + + + + + Qt::AlignVCenter + + + true + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 6 + + + + + + + + When this box is checked, password-strength checking is done and you will not be able to use a weak password. + + + Require strong passwords. + + + + + + + Log in automatically without asking for the password. + + + + + + + Use the same password for the administrator account. + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 6 + + + + + + + + Choose a password for the administrator account. + + + false + + + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + <small>Enter the same password twice, so that it can be checked for typing errors.</small> + + + QLineEdit::Password + + + Password + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + <small>Enter the same password twice, so that it can be checked for typing errors.</small> + + + QLineEdit::Password + + + Repeat Password + + + + + + + + 0 + 0 + + + + + 24 + 24 + + + + + 24 + 24 + + + + true + + + + + + + + 1 + 0 + + + + + 100 + 0 + + + + + + + Qt::AlignVCenter + + + true + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 6 + + + + + + + + + + Use Active Directory + + + + + + + + + + + Domain: + + + + + + + + + + + + + + Domain Administrator: + + + + + + + + + + Password: + + + + + + + QLineEdit::Password + + + + + + + + + + + IP Address (optional): + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 1 + + + + + + + + + diff --git a/calamares/src/modules/users/tests/3-wing.conf b/calamares/src/modules/users/tests/3-wing.conf new file mode 100644 index 0000000..4fc760f --- /dev/null +++ b/calamares/src/modules/users/tests/3-wing.conf @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +defaultGroups: [ wing, wheel, users ] diff --git a/calamares/src/modules/users/tests/4-audio.conf b/calamares/src/modules/users/tests/4-audio.conf new file mode 100644 index 0000000..1280bc2 --- /dev/null +++ b/calamares/src/modules/users/tests/4-audio.conf @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +defaultGroups: + - users + - lp + - wheel + - audio diff --git a/calamares/src/modules/users/tests/5-issue-1523.conf b/calamares/src/modules/users/tests/5-issue-1523.conf new file mode 100644 index 0000000..a0c5e49 --- /dev/null +++ b/calamares/src/modules/users/tests/5-issue-1523.conf @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +defaultGroups: + - adm + - name: foo + must_exist: false + system: true + - name: bar + must_exist: true + - name: foobar + must_exist: false + system: false diff --git a/calamares/src/modules/users/tests/6a-issue-1672.conf b/calamares/src/modules/users/tests/6a-issue-1672.conf new file mode 100644 index 0000000..b8ba242 --- /dev/null +++ b/calamares/src/modules/users/tests/6a-issue-1672.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +autologinGroup: derp +doAutologin: true + diff --git a/calamares/src/modules/users/tests/6b-issue-1672.conf b/calamares/src/modules/users/tests/6b-issue-1672.conf new file mode 100644 index 0000000..a54e71e --- /dev/null +++ b/calamares/src/modules/users/tests/6b-issue-1672.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +autologinGroup: derp +doAutoLogin: true + diff --git a/calamares/src/modules/users/tests/6c-issue-1672.conf b/calamares/src/modules/users/tests/6c-issue-1672.conf new file mode 100644 index 0000000..5d12bd7 --- /dev/null +++ b/calamares/src/modules/users/tests/6c-issue-1672.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +autoLoginGroup: derp +doAutologin: true + diff --git a/calamares/src/modules/users/tests/6d-issue-1672.conf b/calamares/src/modules/users/tests/6d-issue-1672.conf new file mode 100644 index 0000000..80976bf --- /dev/null +++ b/calamares/src/modules/users/tests/6d-issue-1672.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +autoLoginGroup: derp +doAutoLogin: true + diff --git a/calamares/src/modules/users/tests/6e-issue-1672.conf b/calamares/src/modules/users/tests/6e-issue-1672.conf new file mode 100644 index 0000000..df299b4 --- /dev/null +++ b/calamares/src/modules/users/tests/6e-issue-1672.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +doautologin: true +autologingroup: wheel + diff --git a/calamares/src/modules/users/tests/7an-shell.conf b/calamares/src/modules/users/tests/7an-shell.conf new file mode 100644 index 0000000..772ea52 --- /dev/null +++ b/calamares/src/modules/users/tests/7an-shell.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Unset (bogus needed to keep it valid YAML) +user: + # shell: /usr/bin/dash + bogus: true diff --git a/calamares/src/modules/users/tests/7ao-shell.conf b/calamares/src/modules/users/tests/7ao-shell.conf new file mode 100644 index 0000000..e2b49dc --- /dev/null +++ b/calamares/src/modules/users/tests/7ao-shell.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Unset (bogus needed to keep it valid YAML) +# userShell: /usr/bin/dash +bogus: true diff --git a/calamares/src/modules/users/tests/7bn-shell.conf b/calamares/src/modules/users/tests/7bn-shell.conf new file mode 100644 index 0000000..7e568ee --- /dev/null +++ b/calamares/src/modules/users/tests/7bn-shell.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Explicitly empty +user: + shell: "" + diff --git a/calamares/src/modules/users/tests/7bo-shell.conf b/calamares/src/modules/users/tests/7bo-shell.conf new file mode 100644 index 0000000..1ecaf19 --- /dev/null +++ b/calamares/src/modules/users/tests/7bo-shell.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Explicitly empty +userShell: "" + diff --git a/calamares/src/modules/users/tests/7cn-shell.conf b/calamares/src/modules/users/tests/7cn-shell.conf new file mode 100644 index 0000000..a13e449 --- /dev/null +++ b/calamares/src/modules/users/tests/7cn-shell.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Non-absolute path is ignored +user: + shell: dash + diff --git a/calamares/src/modules/users/tests/7co-shell.conf b/calamares/src/modules/users/tests/7co-shell.conf new file mode 100644 index 0000000..b3e2d58 --- /dev/null +++ b/calamares/src/modules/users/tests/7co-shell.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Non-absolute path is ignored +userShell: dash + diff --git a/calamares/src/modules/users/tests/7dn-shell.conf b/calamares/src/modules/users/tests/7dn-shell.conf new file mode 100644 index 0000000..ddca7f4 --- /dev/null +++ b/calamares/src/modules/users/tests/7dn-shell.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Invalid setting (should be string), won't pass validation +user: + shell: [1] + diff --git a/calamares/src/modules/users/tests/7do-shell.conf b/calamares/src/modules/users/tests/7do-shell.conf new file mode 100644 index 0000000..1887149 --- /dev/null +++ b/calamares/src/modules/users/tests/7do-shell.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Invalid setting (should be string), won't pass validation +userShell: [1] + diff --git a/calamares/src/modules/users/tests/7en-shell.conf b/calamares/src/modules/users/tests/7en-shell.conf new file mode 100644 index 0000000..d0a28a7 --- /dev/null +++ b/calamares/src/modules/users/tests/7en-shell.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Explicitly set with full path +user: + shell: /usr/bin/dash + diff --git a/calamares/src/modules/users/tests/7eo-shell.conf b/calamares/src/modules/users/tests/7eo-shell.conf new file mode 100644 index 0000000..e8fbbf7 --- /dev/null +++ b/calamares/src/modules/users/tests/7eo-shell.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Explicitly set with full path +userShell: /usr/bin/dash + diff --git a/calamares/src/modules/users/tests/7fb-shell.conf b/calamares/src/modules/users/tests/7fb-shell.conf new file mode 100644 index 0000000..cd660e8 --- /dev/null +++ b/calamares/src/modules/users/tests/7fb-shell.conf @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Explicitly set with full path +user: + shell: /usr/bin/new + bogus: true + +userShell: /usr/bin/old diff --git a/calamares/src/modules/users/tests/7fn-shell.conf b/calamares/src/modules/users/tests/7fn-shell.conf new file mode 100644 index 0000000..13dca6d --- /dev/null +++ b/calamares/src/modules/users/tests/7fn-shell.conf @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Explicitly set with full path +user: + shell: /usr/bin/new + bogus: true + +# userShell: /usr/bin/old diff --git a/calamares/src/modules/users/tests/7fo-shell.conf b/calamares/src/modules/users/tests/7fo-shell.conf new file mode 100644 index 0000000..c15db23 --- /dev/null +++ b/calamares/src/modules/users/tests/7fo-shell.conf @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +# Explicitly set with full path +user: + # shell: /usr/bin/new + bogus: true + +userShell: /usr/bin/old diff --git a/calamares/src/modules/users/tests/8a-issue-2362.conf b/calamares/src/modules/users/tests/8a-issue-2362.conf new file mode 100644 index 0000000..1b77b74 --- /dev/null +++ b/calamares/src/modules/users/tests/8a-issue-2362.conf @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + forbidden_names: + - me + - myself + - moi + home_permissions: "o700" diff --git a/calamares/src/modules/users/tests/8b-issue-2362.conf b/calamares/src/modules/users/tests/8b-issue-2362.conf new file mode 100644 index 0000000..6ef934b --- /dev/null +++ b/calamares/src/modules/users/tests/8b-issue-2362.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + # Order of names here doesn't matter (and "nobody" and "root" are always added) + forbidden_names: + - myself + - moi + - me + - root + home_permissions: "o755" diff --git a/calamares/src/modules/users/tests/8c-issue-2362.conf b/calamares/src/modules/users/tests/8c-issue-2362.conf new file mode 100644 index 0000000..b229f5c --- /dev/null +++ b/calamares/src/modules/users/tests/8c-issue-2362.conf @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + forbidden_names: + - me + - myself + - moi + home_permissions: "o126" diff --git a/calamares/src/modules/users/tests/8d-issue-2362.conf b/calamares/src/modules/users/tests/8d-issue-2362.conf new file mode 100644 index 0000000..e14683a --- /dev/null +++ b/calamares/src/modules/users/tests/8d-issue-2362.conf @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + forbidden_names: + - me + - myself + - moi + home_permissions: rwx--x--- diff --git a/calamares/src/modules/users/tests/8e-issue-2362.conf b/calamares/src/modules/users/tests/8e-issue-2362.conf new file mode 100644 index 0000000..a9c794e --- /dev/null +++ b/calamares/src/modules/users/tests/8e-issue-2362.conf @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + forbidden_names: + - me + - myself + - moi + home_permissions: -w---xr-- diff --git a/calamares/src/modules/users/tests/8f-issue-2362.conf b/calamares/src/modules/users/tests/8f-issue-2362.conf new file mode 100644 index 0000000..7da938b --- /dev/null +++ b/calamares/src/modules/users/tests/8f-issue-2362.conf @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + forbidden_names: + - me + - myself + - moi + home_permissions: Bogus diff --git a/calamares/src/modules/users/tests/8g-issue-2362.conf b/calamares/src/modules/users/tests/8g-issue-2362.conf new file mode 100644 index 0000000..3921642 --- /dev/null +++ b/calamares/src/modules/users/tests/8g-issue-2362.conf @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + forbidden_names: + - me + - myself + - moi + # This is a number, not a string, due to vagaries of YAML + home_permissions: "0750" diff --git a/calamares/src/modules/users/tests/8h-issue-2362.conf b/calamares/src/modules/users/tests/8h-issue-2362.conf new file mode 100644 index 0000000..a4e7ba5 --- /dev/null +++ b/calamares/src/modules/users/tests/8h-issue-2362.conf @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +--- +user: + shell: /usr/bin/new + forbidden_names: + - me + - myself + - moi + # This is a number, which is interpreted by YAML as decimal even with a leading 0 + home_permissions: 0751 diff --git a/calamares/src/modules/users/users.conf b/calamares/src/modules/users/users.conf new file mode 100644 index 0000000..0a9adf5 --- /dev/null +++ b/calamares/src/modules/users/users.conf @@ -0,0 +1,312 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the one-user-system user module. +# +# Besides these settings, the users module also places the following +# keys into the Global Storage area, based on user input in the view step. +# +# - hostname +# - username +# - password (obscured) +# - autologinUser (if enabled, set to username) +# +# These Global Storage keys are set when the configuration for this module +# is read and when they are modified in the UI. +--- +### GROUPS CONFIGURATION +# +# The system has groups of uses. Some special groups must be +# created during installation. Optionally, there are special +# groups for users who can use sudo and for supporting autologin. + +# Used as default groups for the created user. +# Adjust to your Distribution defaults. +# +# Each entry in the *defaultGroups* list is either: +# - a string, naming a group; this is a **non**-system group +# which does not need to exist in the target system; if it +# does not exist, it will be created. +# - an entry with subkeys *name*, *must_exist* and *system*; +# if the group *must_exist* and does not, an error is thrown +# and the installation fails. +# +# The group is created if it does not exist, and it is +# created as a system group (GID < 1000) or user group +# (GID >= 1000) depending on the value of *system*. +defaultGroups: + - name: users + must_exist: true + system: true + - lp + - video + - network + - storage + - name: wheel + must_exist: false + system: true + - audio + - name: nopasswdlogin + must_exist: false + system: true + +# When *sudoersGroup* is set to a non-empty string, Calamares creates a +# sudoers file for the user. This file is located at: +# `/etc/sudoers.d/10-installer` +# Remember to add the (value of) *sudoersGroup* to *defaultGroups*. +# +# If your Distribution already sets up a group of sudoers in its packaging, +# remove this setting (delete or comment out the line below). Otherwise, +# the setting will be duplicated in the `/etc/sudoers.d/10-installer` file, +# potentially confusing users. +sudoersGroup: wheel + +# Some Distributions require a 'autologin' group for the user. +# Autologin causes a user to become automatically logged in to +# the desktop environment on boot. +# Disable when your Distribution does not require such a group. +# +# Remember to add the (value of) *autologinGroup* to *defaultGroups*. +autologinGroup: autologin + +# See also *user.nopasswd_group* for another group that is optionally added to the user + +### ROOT AND SUDO +# +# Some distributions have a root user enabled for login. Others +# rely entirely on sudo or similar mechanisms to raise privileges. + +# If set to `false` (the default), writes a sudoers file with `ALL=(ALL)` +# so that commands can be run as any user. If set to `true`, writes +# `ALL=(ALL:ALL)` so that any user and any group can be chosen. +sudoersConfigureWithGroup: false + +# Setting this to false, causes the root account to be disabled. +# When disabled, hides the "Use the same password for administrator" +# checkbox. Also hides the "Choose a password" and associated text-inputs. +setRootPassword: true + +# You can control the initial state for the 'reuse password for root' +# checkbox here. Possible values are: +# - true to check or +# - false to uncheck +# +# When checked, the user password is used for the root account too. +# +# NOTE: *doReusePassword* requires *setRootPassword* to be enabled. +doReusePassword: true + + +### PASSWORDS AND LOGIN +# +# Autologin is convenient for single-user systems, but depends on +# the location of the machine if it is practical. "Password strength" +# measures measures might improve security by enforcing hard-to-guess +# passwords, or might encourage a post-it-under-the-keyboard approach. +# Distributions are free to steer their users to one kind of password +# or another. Weak(er) passwords may be allowed, may cause a warning, +# or may be forbidden entirely. + +# Autologin choice can be display to the user. +# Possible values are: +# - true to display it +# - false to hide it +# By default, this value is set to true. +displayAutologin: true + +# You can control the initial state for the 'autologin checkbox' here. +# Possible values are: +# - true to check or +# - false to uncheck +# These set the **initial** state of the checkbox. +doAutologin: true + +# These are optional password-requirements that a distro can enforce +# on the user. The values given in this sample file set only very weak +# validation settings. +# +# Calamares itself supports two checks: +# - minLength +# - maxLength +# In this sample file, the values are set to -1 which means "no +# minimum", "no maximum". This allows any password at all. +# No effort is done to ensure that the checks are consistent +# (e.g. specifying a maximum length less than the minimum length +# will annoy users). +# +# Calamares supports password checking through libpwquality. +# The libpwquality check relies on the (optional) libpwquality library. +# The value for libpwquality is a list of configuration statements like +# those found in pwquality.conf. The statements are handed off to the +# libpwquality parser for evaluation. The check is ignored if +# libpwquality is not available at build time (generates a warning in +# the log). The Calamares password check rejects passwords with a +# score of < 40 with the given libpwquality settings. +# +# (additional checks may be implemented in CheckPWQuality.cpp and +# wired into UsersPage.cpp) +# +# To disable all password validations: +# - comment out the relevant 'passwordRequirements' keys below, +# or set minLength and maxLength to -1. +# - disable libpwquality at build-time. +# To allow all passwords, but provide warnings: +# - set both 'allowWeakPasswords' and 'allowWeakPasswordsDefault' to true. +# (That will show the box *Allow weak passwords* in the user- +# interface, and check it by default). +# - configure password-checking however you wish. +# To require specific password characteristics: +# - set 'allowWeakPasswords' to false (the default) +# - configure password-checking, e.g. with NIST settings + + +# These are very weak -- actually, none at all -- requirements +passwordRequirements: + minLength: -1 # Password at least this many characters + maxLength: -1 # Password at most this many characters + libpwquality: + - minlen=0 + - minclass=0 + +# These are "you must have a password, any password" -- requirements +# +# passwordRequirements: +# minLength: 1 + +# These are requirements the try to follow the suggestions from +# https://pages.nist.gov/800-63-3/sp800-63b.html , "Digital Identity Guidelines". +# Note that requiring long and complex passwords has its own cost, +# because the user has to come up with one at install time. +# Setting 'allowWeakPasswords' to false and 'doAutologin' to false +# will require a strong password and prevent (graphical) login +# without the password. It is likely to be annoying for casual users. +# +# passwordRequirements: +# minLength: 8 +# maxLength: 64 +# libpwquality: +# - minlen=8 +# - maxrepeat=3 +# - maxsequence=3 +# - usersubstr=4 +# - badwords=linux + +# You can control the visibility of the 'strong passwords' checkbox here. +# Possible values are: +# - true to show or +# - false to hide (default) +# the checkbox. This checkbox allows the user to choose to disable +# password-strength-checks. By default the box is **hidden**, so +# that you have to pick a password that satisfies the checks. +allowWeakPasswords: false +# You can control the initial state for the 'strong passwords' checkbox here. +# Possible values are: +# - true to uncheck or +# - false to check (default) +# the checkbox by default. Since the box is labeled to enforce strong +# passwords, in order to **allow** weak ones by default, the box needs +# to be unchecked. +allowWeakPasswordsDefault: false + + +# User settings +# +# The user can enter a username, but there are some other +# hidden settings for the user which are configurable in Calamares. +# +# Key *user* has the following sub-keys: +# +# - *shell* Shell to be used for the regular user of the target system. +# There are three possible kinds of settings: +# - unset (i.e. commented out, the default), act as if set to /bin/bash +# - empty (explicit), don't pass shell information to useradd at all +# and rely on a correct configuration file in /etc/default/useradd +# - set, non-empty, use that path as shell. No validation is done +# that the shell actually exists or is executable. +# - *forbidden_names* Login names that may not be used. This list always +# contains "root" and "nobody", but may be extended to list other special +# names for a given distro (eg. "video", or "mysql" might not be a valid +# end-user login name). +# - *home_permissions* Home directory of the user is given **approximately** +# this set of permissions. If not set, there is no default and no +# permission-setting is done (uses defaults of `useradd` in the target). +# A umask is computed from these permissions +# and passed to `useradd`. +# +# You may write permissions as: +# - write "NNN" (three octal digits) or +# - write "oNNN" (small 'o' and three octal digits) or +# - write "rwxrwxrwx" (like the output of ls, with a - for unset bits) +# The following permissions mean the same thing: "o750", "rwxr-x---" . +# - *nopasswd_group* If set, **and** the user sets no password, then +# the user is added to this group as well. Whether "no password" is +# allowed depends on other settings in this module; distributions that +# require a specific group for "no password" login should set this +# **and** configure the group in the *defaultGroups* section, above. +# The default is unset; this example configuration is non-default. +user: + shell: /bin/bash + forbidden_names: [ root ] + home_permissions: "o700" + nopasswd_group: "nopasswdlogin" + + +# Hostname settings +# +# The user can enter a hostname; this is configured into the system +# in some way. There are settings for how a hostname is guessed (as +# a default / suggestion) and where (or how) the hostname is set in +# the target system. +# +# Key *hostname* has the following sub-keys: +# +# - *location* How the hostname is set in the target system: +# - *None*, to not set the hostname at all +# - *EtcFile*, to write to `/etc/hostname` directly +# - *Etc*, identical to above +# - *Hostnamed*, to use systemd hostnamed(1) over DBus +# - *Transient*, to remove `/etc/hostname` from the target +# The default is *EtcFile*. Setting this to *None* or *Transient* will +# hide the hostname field. +# - *writeHostsFile* Should /etc/hosts be written with a hostname for +# this machine (also adds localhost and some ipv6 standard entries). +# Defaults to *true*. +# - *template* Is a simple template for making a suggestion for the +# hostname, based on user data. The default is "${first}-${product}". +# This is used only if the hostname field is shown. KMacroExpander is +# used; write `${key}` where `key` is one of the following: +# - *first* User's first name (whatever is first in the User Name field, +# which is first-in-order but not necessarily a "first name" as in +# "given name" or "name by which you call someone"; beware of western bias) +# - *name* All the text in the User Name field. +# - *login* The login name (which may be suggested based on User Name) +# - *product* The hardware product, based on DMI data +# - *product2* The product as described by Qt +# - *cpu* CPU name +# - *host* Current hostname (which may be a transient hostname) +# Literal text in the template is preserved. Calamares tries to map +# `${key}` values to something that will fit in a hostname, but does not +# apply the same to literal text in the template. Do not use invalid +# characters in the literal text, or no suggeston will be done. +# - *forbidden_names* lists hostnames that may not be used. This list +# always contains "localhost", but may list others that are unsuitable +# or broken in special ways. +hostname: + location: EtcFile + writeHostsFile: true + template: "derp-${cpu}" + forbidden_names: [ localhost ] + +# Enable Active Directory enrollment support (opt-in) +# +# This uses realmd to enroll the machine in an Active Directory server +# It requires realmd as a runtime dependency of Calamares, if enabled +allowActiveDirectory: false + +presets: + fullName: + # value: "OEM User" + editable: true + loginName: + # value: "oem" + editable: true diff --git a/calamares/src/modules/users/users.qrc b/calamares/src/modules/users/users.qrc new file mode 100644 index 0000000..70392c3 --- /dev/null +++ b/calamares/src/modules/users/users.qrc @@ -0,0 +1,6 @@ + + + images/invalid.png + images/valid.png + + diff --git a/calamares/src/modules/users/users.schema.yaml b/calamares/src/modules/users/users.schema.yaml new file mode 100644 index 0000000..9d24593 --- /dev/null +++ b/calamares/src/modules/users/users.schema.yaml @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/users +additionalProperties: false +type: object +properties: + user: + additionalProperties: false + type: object + properties: + # User shell, should be path to /bin/sh or so + shell: { type: string } + forbidden_names: { type: array, items: { type: string } } + home_permissions: { type: string } + nopasswd_group: { type: string } + # Group settings + defaultGroups: + type: array + items: + oneOf: + - type: string + - type: object + properties: + name: { type: string } + must_exist: { type: boolean, default: false } + system: { type: boolean, default: false } + additionalProperties: false + required: [ name ] + autologinGroup: { type: string } + sudoersGroup: { type: string } + sudoersConfigureWithGroup: { type: boolean, default: false } + # Skip login (depends on displaymanager support) + displayAutologin: { type: boolean, default: true } + doAutologin: { type: boolean, default: true } + # Root password separate from user password? + setRootPassword: { type: boolean, default: true } + doReusePassword: { type: boolean, default: true } + # Passwords that don't pass a quality test + allowWeakPasswords: { type: boolean, default: false } + allowWeakPasswordsDefault: { type: boolean, default: false } + passwordRequirements: + additionalProperties: false + type: object + properties: + minLength: { type: number } + maxLength: { type: number } + libpwquality: { type: array, items: { type: string } } # Don't know what libpwquality supports + hostname: + additionalProperties: false + type: object + properties: + location: { type: string, enum: [ None, EtcFile, Hostnamed, Transient ] } + writeHostsFile: { type: boolean, default: true } + template: { type: string, default: "${first}-${product}" } + forbidden_names: { type: array, items: { type: string } } + allowActiveDirectory: { type: boolean, default: false } + + # Presets + # + # TODO: lift up somewhere, since this will return in many modules; + # the type for each field (fullName, loginName) is a + # preset-description (value, editable). + presets: + type: object + additionalProperties: false + properties: + fullName: { type: object } + loginName: { type: object } + +required: + - defaultGroups + - autologinGroup + - sudoersGroup diff --git a/calamares/src/modules/usersq/CMakeLists.txt b/calamares/src/modules/usersq/CMakeLists.txt new file mode 100644 index 0000000..5cfd558 --- /dev/null +++ b/calamares/src/modules/usersq/CMakeLists.txt @@ -0,0 +1,51 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +if(NOT WITH_QML) + calamares_skip_module( "usersq (QML is not supported in this build)" ) + return() +endif() + +find_package(${qtname} ${QT_VERSION} CONFIG REQUIRED Core DBus Network) +find_package(Crypt REQUIRED) + +# Add optional libraries here +set(USER_EXTRA_LIB) + +include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../users) + +find_package(LibPWQuality) +set_package_properties(LibPWQuality PROPERTIES PURPOSE "Extra checks of password quality") + +if(LibPWQuality_FOUND) + list(APPEND USER_EXTRA_LIB ${LibPWQuality_LIBRARIES}) + include_directories(${LibPWQuality_INCLUDE_DIRS}) + add_definitions(-DCHECK_PWQUALITY -DHAVE_LIBPWQUALITY) +endif() + +#needed for ${_users}/Config.cpp +find_package(ICU COMPONENTS uc i18n) +set_package_properties(ICU PROPERTIES PURPOSE "Transliteration support for full name to username conversion") + +if(ICU_FOUND) + list(APPEND USER_EXTRA_LIB ICU::uc ICU::i18n) + include_directories(${ICU_INCLUDE_DIRS}) + add_definitions(-DHAVE_ICU) +endif() + +calamares_add_plugin(usersq + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + UsersQmlViewStep.cpp + RESOURCES + usersq${QT_VERSION_SUFFIX}.qrc + LINK_PRIVATE_LIBRARIES + users_internal + ${CRYPT_LIBRARIES} + ${USER_EXTRA_LIB} + ${qtname}::DBus + SHARED_LIB +) diff --git a/calamares/src/modules/usersq/UsersQmlViewStep.cpp b/calamares/src/modules/usersq/UsersQmlViewStep.cpp new file mode 100644 index 0000000..cc35c0b --- /dev/null +++ b/calamares/src/modules/usersq/UsersQmlViewStep.cpp @@ -0,0 +1,79 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 Adriaan de Groot + * SPDX-FileCopyrightText: 2017 Gabriel Craciunescu + * SPDX-FileCopyrightText: 2020 Camilo Higuita + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "UsersQmlViewStep.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Variant.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( UsersQmlViewStepFactory, registerPlugin< UsersQmlViewStep >(); ) + +UsersQmlViewStep::UsersQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) + , m_config( new Config( this ) ) +{ + connect( m_config, &Config::readyChanged, this, &UsersQmlViewStep::nextStatusChanged ); + + emit nextStatusChanged( true ); +} + +QString +UsersQmlViewStep::prettyName() const +{ + return tr( "Users" ); +} + +bool +UsersQmlViewStep::isNextEnabled() const +{ + return m_config->isReady(); +} + +bool +UsersQmlViewStep::isBackEnabled() const +{ + return true; +} + +bool +UsersQmlViewStep::isAtBeginning() const +{ + return true; +} + +bool +UsersQmlViewStep::isAtEnd() const +{ + return true; +} + +Calamares::JobList +UsersQmlViewStep::jobs() const +{ + return m_config->createJobs(); +} + +void +UsersQmlViewStep::onLeave() +{ + m_config->finalizeGlobalStorage(); +} + +void +UsersQmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); + Calamares::QmlViewStep::setConfigurationMap( configurationMap ); // call parent implementation last +} diff --git a/calamares/src/modules/usersq/UsersQmlViewStep.h b/calamares/src/modules/usersq/UsersQmlViewStep.h new file mode 100644 index 0000000..e98df9d --- /dev/null +++ b/calamares/src/modules/usersq/UsersQmlViewStep.h @@ -0,0 +1,54 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 Camilo Higuita + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef USERSQMLVIEWSTEP_H +#define USERSQMLVIEWSTEP_H + +// Config from users module +#include "Config.h" + +#include "DllMacro.h" +#include "utils/PluginFactory.h" +#include "viewpages/QmlViewStep.h" + +#include +#include + +class PLUGINDLLEXPORT UsersQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + explicit UsersQmlViewStep( QObject* parent = nullptr ); + + QString prettyName() const override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void onLeave() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + QObject* getConfig() override { return m_config; } + +private: + Config* m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( UsersQmlViewStepFactory ) + +#endif // USERSQMLVIEWSTEP_H diff --git a/calamares/src/modules/usersq/usersq-qt6.qml b/calamares/src/modules/usersq/usersq-qt6.qml new file mode 100644 index 0000000..ea17ac4 --- /dev/null +++ b/calamares/src/modules/usersq/usersq-qt6.qml @@ -0,0 +1,425 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2022 Anke Boersma + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import org.kde.kirigami as Kirigami +import QtQuick.Window + +Kirigami.ScrollablePage { + // You can hard-code a color here, or bind to a Kirigami theme color, + // or use a color from Calamares branding, or .. + readonly property color unfilledFieldColor: "#FBFBFB" //Kirigami.Theme.backgroundColor + readonly property color positiveFieldColor: "#F0FFF0" //Kirigami.Theme.positiveBackgroundColor + readonly property color negativeFieldColor: "#EBCED1" //Kirigami.Theme.negativeBackgroundColor + readonly property color unfilledFieldOutlineColor: "#F1F1F1" + readonly property color positiveFieldOutlineColor: "#DCFFDC" + readonly property color negativeFieldOutlineColor: "#BE5F68" + readonly property color headerTextColor: "#1F1F1F" + readonly property color commentsColor: "#6D6D6D" + + width: parent.width + height: parent.height + + header: Kirigami.Heading { + Layout.fillWidth: true + height: 50 + horizontalAlignment: Qt.AlignHCenter + color: headerTextColor + font.weight: Font.Medium + font.pointSize: 12 + text: qsTr("Pick your user name and credentials to login and perform admin tasks") + } + + ColumnLayout { + id: _formLayout + spacing: Kirigami.Units.smallSpacing + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("What is your name?") + } + + TextField { + id: _userNameField + width: parent.width + enabled: config.isEditable("fullName") + placeholderText: qsTr("Your full name") + text: config.fullName + onTextChanged: config.setFullName(text) + + palette.base: _userNameField.text.length + ? positiveFieldColor : unfilledFieldColor + palette.highlight : _userNameField.text.length + ? positiveFieldOutlineColor : unfilledFieldOutlineColor + } + } + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("What name do you want to use to log in?") + } + + TextField { + id: _userLoginField + width: parent.width + enabled: config.isEditable("loginName") + placeholderText: qsTr("Login name") + text: config.loginName + validator: RegularExpressionValidator { regularExpression: /[a-z_][a-z0-9_-]*[$]?$/ } + + onTextChanged: acceptableInput + ? ( _userLoginField.text === "root" + ? forbiddenMessage.visible=true + : ( config.setLoginName(text), + userMessage.visible = false,forbiddenMessage.visible=false ) ) + : ( userMessage.visible = true,console.log("Invalid") ) + + palette.base: _userLoginField.text.length + ? ( acceptableInput + ? ( _userLoginField.text === "root" + ? negativeFieldColor + : positiveFieldColor ) + : negativeFieldColor ) + : unfilledFieldColor + palette.highlight : _userLoginField.text.length + ? ( acceptableInput + ? ( _userLoginField.text === "root" + ? negativeFieldOutlineColor + : positiveFieldOutlineColor ) + : negativeFieldOutlineColor ) + : unfilledFieldOutlineColor + } + + Label { + width: parent.width + text: qsTr("If more than one person will use this computer, you can create multiple accounts after installation.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: userMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("Only lowercase letters, numbers, underscore and hyphen are allowed.") + } + + Kirigami.InlineMessage { + id: forbiddenMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("root is not allowed as username.") + } + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("What is the name of this computer?") + } + + TextField { + id: _hostName + width: parent.width + placeholderText: qsTr("Computer name") + text: config.hostname + validator: RegularExpressionValidator { regularExpression: /[a-zA-Z0-9][-a-zA-Z0-9_]+/ } + + onTextChanged: acceptableInput + ? ( _hostName.text === "localhost" + ? forbiddenHost.visible=true + : ( config.setHostName(text), + hostMessage.visible = false,forbiddenHost.visible = false ) ) + : hostMessage.visible = true + + palette.base: _hostName.text.length + ? ( acceptableInput + ? ( _hostName.text === "localhost" + ? negativeFieldColor : positiveFieldColor ) + : negativeFieldColor) + : unfilledFieldColor + palette.highlight : _hostName.text.length + ? ( acceptableInput + ? ( _hostName.text === "localhost" + ? negativeFieldOutlineColor : positiveFieldOutlineColor ) + : negativeFieldOutlineColor) + : unfilledFieldOutlineColor + } + + Label { + width: parent.width + text: qsTr("This name will be used if you make the computer visible to others on a network.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: hostMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("Only letters, numbers, underscore and hyphen are allowed, minimal of two characters.") + } + + Kirigami.InlineMessage { + id: forbiddenHost + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("localhost is not allowed as hostname.") + } + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("Choose a password to keep your account safe.") + } + + Row { + width: parent.width + spacing: 20 + + TextField { + id: _passwordField + width: parent.width / 2 - 10 + placeholderText: qsTr("Password") + text: config.userPassword + onTextChanged: config.setUserPassword(text) + + palette.base: _passwordField.text.length + ? positiveFieldColor : unfilledFieldColor + palette.highlight : _passwordField.text.length + ? positiveFieldOutlineColor : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + + TextField { + id: _verificationPasswordField + width: parent.width / 2 - 10 + placeholderText: qsTr("Repeat password") + text: config.userPasswordSecondary + + onTextChanged: _passwordField.text === _verificationPasswordField.text + ? ( config.setUserPasswordSecondary(text), + passMessage.visible = false, + validityMessage.visible = true ) + : ( passMessage.visible = true, + validityMessage.visible = false ) + + palette.base: _verificationPasswordField.text.length + ? ( _passwordField.text === _verificationPasswordField.text + ? positiveFieldColor : negativeFieldColor ) + : unfilledFieldColor + palette.highlight : _verificationPasswordField.text.length + ? ( _passwordField.text === _verificationPasswordField.text + ? positiveFieldOutlineColor : negativeFieldOutlineColor ) + : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + } + + Label { + width: parent.width + text: qsTr("Enter the same password twice, so that it can be checked for typing errors. A good password will contain a mixture of letters, numbers and punctuation, should be at least eight characters long, and should be changed at regular intervals.") + font.weight: Font.Thin + font.pointSize: 8 + wrapMode: Text.WordWrap + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: passMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: config.userPasswordMessage + } + + Kirigami.InlineMessage { + id: validityMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: config.userPasswordValidity + ? ( config.requireStrongPasswords + ? Kirigami.MessageType.Error : Kirigami.MessageType.Warning ) + : Kirigami.MessageType.Positive + text: config.userPasswordMessage + } + + CheckBox { + id: root + visible: config.writeRootPassword + text: qsTr("Reuse user password as root password") + checked: config.reuseUserPasswordForRoot + onCheckedChanged: config.setReuseUserPasswordForRoot(checked) + } + + Label { + visible: root.checked + width: parent.width + text: qsTr("Use the same password for the administrator account.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + + Column { + visible: ! root.checked + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("Choose a root password to keep your account safe.") + } + + Row { + width: parent.width + spacing: 20 + + TextField { + id: _rootPasswordField + width: parent.width / 2 -10 + placeholderText: qsTr("Root password") + text: config.rootPassword + + onTextChanged: config.setRootPassword(text) + + palette.base: _rootPasswordField.text.length + ? positiveFieldColor : unfilledFieldColor + palette.highlight : _rootPasswordField.text.length + ? positiveFieldOutlineColor : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + + TextField { + id: _verificationRootPasswordField + width: parent.width / 2 -10 + placeholderText: qsTr("Repeat root password") + text: config.rootPasswordSecondary + + onTextChanged: _rootPasswordField.text === _verificationRootPasswordField.text + ? ( config.setRootPasswordSecondary(text), + rootPassMessage.visible = false,rootValidityMessage.visible = true ) + : ( rootPassMessage.visible = true,rootValidityMessage.visible = false ) + + palette.base: _verificationRootPasswordField.text.length + ? ( _rootPasswordField.text === _verificationRootPasswordField.text + ? positiveFieldColor : negativeFieldColor) + : unfilledFieldColor + palette.highlight : _verificationRootPasswordField.text.length + ? ( _rootPasswordField.text === _verificationRootPasswordField.text + ? positiveFieldOutlineColor : negativeFieldOutlineColor) + : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + } + + Label { + visible: ! root.checked + width: parent.width + text: qsTr("Enter the same password twice, so that it can be checked for typing errors.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: rootPassMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: config.rootPasswordMessage + } + + Kirigami.InlineMessage { + id: rootValidityMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: config.rootPasswordValidity + ? ( config.requireStrongPasswords + ? Kirigami.MessageType.Error : Kirigami.MessageType.Warning ) + : Kirigami.MessageType.Positive + text: config.rootPasswordMessage + } + + CheckBox { + Layout.alignment: Qt.AlignCenter + text: qsTr("Log in automatically without asking for the password") + checked: config.doAutoLogin + onCheckedChanged: config.setAutoLogin(checked) + } + + CheckBox { + visible: config.permitWeakPasswords + Layout.alignment: Qt.AlignCenter + text: qsTr("Validate passwords quality") + checked: config.requireStrongPasswords + onCheckedChanged: config.setRequireStrongPasswords(checked), + rootPassMessage.visible = false + } + + Label { + visible: config.permitWeakPasswords + width: parent.width + Layout.alignment: Qt.AlignCenter + text: qsTr("When this box is checked, password-strength checking is done and you will not be able to use a weak password.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } +} diff --git a/calamares/src/modules/usersq/usersq-qt6.qrc b/calamares/src/modules/usersq/usersq-qt6.qrc new file mode 100644 index 0000000..98dcf61 --- /dev/null +++ b/calamares/src/modules/usersq/usersq-qt6.qrc @@ -0,0 +1,5 @@ + + + usersq-qt6.qml + + diff --git a/calamares/src/modules/usersq/usersq.conf b/calamares/src/modules/usersq/usersq.conf new file mode 100644 index 0000000..ea171bd --- /dev/null +++ b/calamares/src/modules/usersq/usersq.conf @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# For documentation see Users Module users.conf +# +--- +# Used as default groups for the created user. +# Adjust to your Distribution defaults. +defaultGroups: + - users + - lp + - video + - network + - storage + - wheel + - audio + - lpadmin + +autologinGroup: autologin + +doAutologin: true + +sudoersGroup: wheel + +setRootPassword: true + +doReusePassword: true + +passwordRequirements: + minLength: -1 + maxLength: -1 + libpwquality: + - minlen=0 + - minclass=0 + +allowWeakPasswords: false + +allowWeakPasswordsDefault: false + +userShell: /bin/bash + +setHostname: EtcFile + +writeHostsFile: true diff --git a/calamares/src/modules/usersq/usersq.qml b/calamares/src/modules/usersq/usersq.qml new file mode 100644 index 0000000..d057f64 --- /dev/null +++ b/calamares/src/modules/usersq/usersq.qml @@ -0,0 +1,426 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2022 Anke Boersma + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.15 +import QtQuick.Controls 2.10 +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.7 as Kirigami +import QtGraphicalEffects 1.0 +import QtQuick.Window 2.3 + +Kirigami.ScrollablePage { + // You can hard-code a color here, or bind to a Kirigami theme color, + // or use a color from Calamares branding, or .. + readonly property color unfilledFieldColor: "#FBFBFB" //Kirigami.Theme.backgroundColor + readonly property color positiveFieldColor: "#F0FFF0" //Kirigami.Theme.positiveBackgroundColor + readonly property color negativeFieldColor: "#EBCED1" //Kirigami.Theme.negativeBackgroundColor + readonly property color unfilledFieldOutlineColor: "#F1F1F1" + readonly property color positiveFieldOutlineColor: "#DCFFDC" + readonly property color negativeFieldOutlineColor: "#BE5F68" + readonly property color headerTextColor: "#1F1F1F" + readonly property color commentsColor: "#6D6D6D" + + width: parent.width + height: parent.height + + header: Kirigami.Heading { + Layout.fillWidth: true + height: 50 + horizontalAlignment: Qt.AlignHCenter + color: headerTextColor + font.weight: Font.Medium + font.pointSize: 12 + text: qsTr("Pick your user name and credentials to login and perform admin tasks") + } + + ColumnLayout { + id: _formLayout + spacing: Kirigami.Units.smallSpacing + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("What is your name?") + } + + TextField { + id: _userNameField + width: parent.width + enabled: config.isEditable("fullName") + placeholderText: qsTr("Your full name") + text: config.fullName + onTextChanged: config.setFullName(text) + + palette.base: _userNameField.text.length + ? positiveFieldColor : unfilledFieldColor + palette.highlight : _userNameField.text.length + ? positiveFieldOutlineColor : unfilledFieldOutlineColor + } + } + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("What name do you want to use to log in?") + } + + TextField { + id: _userLoginField + width: parent.width + enabled: config.isEditable("loginName") + placeholderText: qsTr("Login name") + text: config.loginName + validator: RegularExpressionValidator { regularExpression: /[a-z_][a-z0-9_-]*[$]?$/ } + + onTextChanged: acceptableInput + ? ( _userLoginField.text === "root" + ? forbiddenMessage.visible=true + : ( config.setLoginName(text), + userMessage.visible = false,forbiddenMessage.visible=false ) ) + : ( userMessage.visible = true,console.log("Invalid") ) + + palette.base: _userLoginField.text.length + ? ( acceptableInput + ? ( _userLoginField.text === "root" + ? negativeFieldColor + : positiveFieldColor ) + : negativeFieldColor ) + : unfilledFieldColor + palette.highlight : _userLoginField.text.length + ? ( acceptableInput + ? ( _userLoginField.text === "root" + ? negativeFieldOutlineColor + : positiveFieldOutlineColor ) + : negativeFieldOutlineColor ) + : unfilledFieldOutlineColor + } + + Label { + width: parent.width + text: qsTr("If more than one person will use this computer, you can create multiple accounts after installation.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: userMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("Only lowercase letters, numbers, underscore and hyphen are allowed.") + } + + Kirigami.InlineMessage { + id: forbiddenMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("root is not allowed as username.") + } + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("What is the name of this computer?") + } + + TextField { + id: _hostName + width: parent.width + placeholderText: qsTr("Computer name") + text: config.hostname + validator: RegularExpressionValidator { regularExpression: /[a-zA-Z0-9][-a-zA-Z0-9_]+/ } + + onTextChanged: acceptableInput + ? ( _hostName.text === "localhost" + ? forbiddenHost.visible=true + : ( config.setHostName(text), + hostMessage.visible = false,forbiddenHost.visible = false ) ) + : hostMessage.visible = true + + palette.base: _hostName.text.length + ? ( acceptableInput + ? ( _hostName.text === "localhost" + ? negativeFieldColor : positiveFieldColor ) + : negativeFieldColor) + : unfilledFieldColor + palette.highlight : _hostName.text.length + ? ( acceptableInput + ? ( _hostName.text === "localhost" + ? negativeFieldOutlineColor : positiveFieldOutlineColor ) + : negativeFieldOutlineColor) + : unfilledFieldOutlineColor + } + + Label { + width: parent.width + text: qsTr("This name will be used if you make the computer visible to others on a network.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: hostMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("Only letters, numbers, underscore and hyphen are allowed, minimal of two characters.") + } + + Kirigami.InlineMessage { + id: forbiddenHost + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: qsTr("localhost is not allowed as hostname.") + } + + Column { + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("Choose a password to keep your account safe.") + } + + Row { + width: parent.width + spacing: 20 + + TextField { + id: _passwordField + width: parent.width / 2 - 10 + placeholderText: qsTr("Password") + text: config.userPassword + onTextChanged: config.setUserPassword(text) + + palette.base: _passwordField.text.length + ? positiveFieldColor : unfilledFieldColor + palette.highlight : _passwordField.text.length + ? positiveFieldOutlineColor : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + + TextField { + id: _verificationPasswordField + width: parent.width / 2 - 10 + placeholderText: qsTr("Repeat password") + text: config.userPasswordSecondary + + onTextChanged: _passwordField.text === _verificationPasswordField.text + ? ( config.setUserPasswordSecondary(text), + passMessage.visible = false, + validityMessage.visible = true ) + : ( passMessage.visible = true, + validityMessage.visible = false ) + + palette.base: _verificationPasswordField.text.length + ? ( _passwordField.text === _verificationPasswordField.text + ? positiveFieldColor : negativeFieldColor ) + : unfilledFieldColor + palette.highlight : _verificationPasswordField.text.length + ? ( _passwordField.text === _verificationPasswordField.text + ? positiveFieldOutlineColor : negativeFieldOutlineColor ) + : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + } + + Label { + width: parent.width + text: qsTr("Enter the same password twice, so that it can be checked for typing errors. A good password will contain a mixture of letters, numbers and punctuation, should be at least eight characters long, and should be changed at regular intervals.") + font.weight: Font.Thin + font.pointSize: 8 + wrapMode: Text.WordWrap + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: passMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: config.userPasswordMessage + } + + Kirigami.InlineMessage { + id: validityMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: config.userPasswordValidity + ? ( config.requireStrongPasswords + ? Kirigami.MessageType.Error : Kirigami.MessageType.Warning ) + : Kirigami.MessageType.Positive + text: config.userPasswordMessage + } + + CheckBox { + id: root + visible: config.writeRootPassword + text: qsTr("Reuse user password as root password") + checked: config.reuseUserPasswordForRoot + onCheckedChanged: config.setReuseUserPasswordForRoot(checked) + } + + Label { + visible: root.checked + width: parent.width + text: qsTr("Use the same password for the administrator account.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + + Column { + visible: ! root.checked + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + + Label { + width: parent.width + text: qsTr("Choose a root password to keep your account safe.") + } + + Row { + width: parent.width + spacing: 20 + + TextField { + id: _rootPasswordField + width: parent.width / 2 -10 + placeholderText: qsTr("Root password") + text: config.rootPassword + + onTextChanged: config.setRootPassword(text) + + palette.base: _rootPasswordField.text.length + ? positiveFieldColor : unfilledFieldColor + palette.highlight : _rootPasswordField.text.length + ? positiveFieldOutlineColor : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + + TextField { + id: _verificationRootPasswordField + width: parent.width / 2 -10 + placeholderText: qsTr("Repeat root password") + text: config.rootPasswordSecondary + + onTextChanged: _rootPasswordField.text === _verificationRootPasswordField.text + ? ( config.setRootPasswordSecondary(text), + rootPassMessage.visible = false,rootValidityMessage.visible = true ) + : ( rootPassMessage.visible = true,rootValidityMessage.visible = false ) + + palette.base: _verificationRootPasswordField.text.length + ? ( _rootPasswordField.text === _verificationRootPasswordField.text + ? positiveFieldColor : negativeFieldColor) + : unfilledFieldColor + palette.highlight : _verificationRootPasswordField.text.length + ? ( _rootPasswordField.text === _verificationRootPasswordField.text + ? positiveFieldOutlineColor : negativeFieldOutlineColor) + : unfilledFieldOutlineColor + + echoMode: TextInput.Password + passwordMaskDelay: 300 + inputMethodHints: Qt.ImhNoAutoUppercase + } + } + + Label { + visible: ! root.checked + width: parent.width + text: qsTr("Enter the same password twice, so that it can be checked for typing errors.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } + + Kirigami.InlineMessage { + id: rootPassMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: Kirigami.MessageType.Error + text: config.rootPasswordMessage + } + + Kirigami.InlineMessage { + id: rootValidityMessage + Layout.fillWidth: true + showCloseButton: true + visible: false + type: config.rootPasswordValidity + ? ( config.requireStrongPasswords + ? Kirigami.MessageType.Error : Kirigami.MessageType.Warning ) + : Kirigami.MessageType.Positive + text: config.rootPasswordMessage + } + + CheckBox { + Layout.alignment: Qt.AlignCenter + text: qsTr("Log in automatically without asking for the password") + checked: config.doAutoLogin + onCheckedChanged: config.setAutoLogin(checked) + } + + CheckBox { + visible: config.permitWeakPasswords + Layout.alignment: Qt.AlignCenter + text: qsTr("Validate passwords quality") + checked: config.requireStrongPasswords + onCheckedChanged: config.setRequireStrongPasswords(checked), + rootPassMessage.visible = false + } + + Label { + visible: config.permitWeakPasswords + width: parent.width + Layout.alignment: Qt.AlignCenter + text: qsTr("When this box is checked, password-strength checking is done and you will not be able to use a weak password.") + font.weight: Font.Thin + font.pointSize: 8 + color: commentsColor + } + } +} diff --git a/calamares/src/modules/usersq/usersq.qrc b/calamares/src/modules/usersq/usersq.qrc new file mode 100644 index 0000000..8c1c4f9 --- /dev/null +++ b/calamares/src/modules/usersq/usersq.qrc @@ -0,0 +1,5 @@ + + + usersq.qml + + diff --git a/calamares/src/modules/welcome/CMakeLists.txt b/calamares/src/modules/welcome/CMakeLists.txt new file mode 100644 index 0000000..19714e9 --- /dev/null +++ b/calamares/src/modules/welcome/CMakeLists.txt @@ -0,0 +1,46 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +find_package(${qtname} ${QT_VERSION} CONFIG REQUIRED DBus Network) + +find_package(LIBPARTED) +if(LIBPARTED_FOUND) + set(PARTMAN_SRC checker/partman_devices.c) + set(PARTMAN_LIB ${LIBPARTED_LIBRARY}) +else() + set(PARTMAN_SRC) + set(PARTMAN_LIB) + add_definitions(-DWITHOUT_LIBPARTED) +endif() + +calamares_add_plugin(welcome + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + checker/CheckerContainer.cpp + checker/GeneralRequirements.cpp + checker/ResultDelegate.cpp + checker/ResultsListWidget.cpp + ${PARTMAN_SRC} + WelcomeViewStep.cpp + Config.cpp + Config.h + WelcomePage.cpp + UI + WelcomePage.ui + RESOURCES + welcome.qrc + LINK_PRIVATE_LIBRARIES + ${PARTMAN_LIB} + ${qtname}::DBus + ${qtname}::Network + SHARED_LIB +) + +calamares_add_test( + welcometest + SOURCES checker/GeneralRequirements.cpp ${PARTMAN_SRC} Config.cpp Tests.cpp + LIBRARIES ${PARTMAN_LIB} ${qtname}::DBus ${qtname}::Network ${qtname}::Widgets Calamares::calamaresui +) diff --git a/calamares/src/modules/welcome/Config.cpp b/calamares/src/modules/welcome/Config.cpp new file mode 100644 index 0000000..ad5e2d8 --- /dev/null +++ b/calamares/src/modules/welcome/Config.cpp @@ -0,0 +1,438 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "Branding.h" +#include "CalamaresAbout.h" +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "compat/Variant.h" +#include "geoip/Handler.h" +#include "locale/Global.h" +#include "locale/Lookup.h" +#include "modulesystem/ModuleManager.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/Variant.h" + +#include + +Config::Config( QObject* parent ) + : QObject( parent ) + , m_languages( Calamares::Locale::availableTranslations() ) + , m_filtermodel( std::make_unique< QSortFilterProxyModel >() ) + , m_requirementsChecker( std::make_unique< GeneralRequirements >( this ) ) +{ + initLanguages(); + + CALAMARES_RETRANSLATE_SLOT( &Config::retranslate ); + // But also when the requirements model changes, update the messages + connect( requirementsModel(), &Calamares::RequirementsModel::progressMessageChanged, this, &Config::retranslate ); +} + +void +Config::retranslate() +{ + const auto* branding = Calamares::Branding::instance(); + const auto* settings = Calamares::Settings::instance(); + m_genericWelcomeMessage = genericWelcomeMessage().arg( branding ? branding->versionedName() : QString() ); + emit genericWelcomeMessageChanged( m_genericWelcomeMessage ); + + const auto* r = requirementsModel(); + if ( r && !r->satisfiedRequirements() ) + { + QString message; + const bool setup = settings ? settings->isSetupMode() : false; + + if ( !r->satisfiedMandatory() ) + { + message = setup ? tr( "This computer does not satisfy the minimum " + "requirements for setting up %1.
    " + "Setup cannot continue." ) + : tr( "This computer does not satisfy the minimum " + "requirements for installing %1.
    " + "Installation cannot continue." ); + } + else + { + message = setup ? tr( "This computer does not satisfy some of the " + "recommended requirements for setting up %1.
    " + "Setup can continue, but some features " + "might be disabled." ) + : tr( "This computer does not satisfy some of the " + "recommended requirements for installing %1.
    " + "Installation can continue, but some features " + "might be disabled." ); + } + + m_warningMessage = message.arg( branding ? branding->shortVersionedName() : QString() ); + } + else + { + m_warningMessage = tr( "This program will ask you some questions and " + "set up %2 on your computer." ) + .arg( branding ? branding->productName() : QString() ); + } + + emit warningMessageChanged( m_warningMessage ); +} + +Calamares::Locale::TranslationsModel* +Config::languagesModel() const +{ + return m_languages; +} + +Calamares::RequirementsModel* +Config::requirementsModel() const +{ + auto* manager = Calamares::ModuleManager::instance(); + return manager ? manager->requirementsModel() : nullptr; +} + +QAbstractItemModel* +Config::unsatisfiedRequirements() const +{ + if ( !m_filtermodel->sourceModel() ) + { + m_filtermodel->setFilterRole( Calamares::RequirementsModel::Roles::Satisfied ); + m_filtermodel->setFilterFixedString( QStringLiteral( "false" ) ); + m_filtermodel->setSourceModel( requirementsModel() ); + } + return m_filtermodel.get(); +} + +QString +Config::languageIcon() const +{ + return m_languageIcon; +} + +static bool +languageAndTerritoryMatch( const QLocale& a, const QLocale& b ) +{ + const bool languageMatch = a.language() == b.language(); +#if QT_VERSION < QT_VERSION_CHECK( 6, 6, 0 ) + const bool placeMatch = a.country() == b.country(); +#else + const bool placeMatch = a.territory() == b.territory(); +#endif + return languageMatch && placeMatch; +} + +void +Config::initLanguages() +{ + // Find the best initial translation + Calamares::Locale::Translation defaultTranslation; + + cDebug() << "Trying to match locale" << defaultTranslation.id(); + int matchedLocaleIndex = m_languages->find( defaultTranslation.id() ); + + // Need to match by some other means than the exact translation Id + if ( matchedLocaleIndex < 0 ) + { + QLocale defaultLocale = defaultTranslation.locale(); + + cDebug() << "Trying to match locale" << defaultLocale; + matchedLocaleIndex = m_languages->find( [ &defaultLocale ]( const QLocale& x ) + { return languageAndTerritoryMatch( defaultLocale, x ); } ); + + if ( matchedLocaleIndex < 0 ) + { + cDebug() << Logger::SubEntry << "Trying to match approximate locale" << defaultLocale.language(); + + matchedLocaleIndex + = m_languages->find( [ & ]( const QLocale& x ) { return x.language() == defaultLocale.language(); } ); + } + + if ( matchedLocaleIndex < 0 ) + { + QLocale en_us( QLocale::English, QLocale::UnitedStates ); + + cDebug() << Logger::SubEntry << "Trying to match English (US)"; + matchedLocaleIndex = m_languages->find( en_us ); + + // Now, if it matched, because we didn't match the system locale, switch to the one found + if ( matchedLocaleIndex >= 0 ) + { + QLocale::setDefault( m_languages->locale( matchedLocaleIndex ).locale() ); + } + } + } + + if ( matchedLocaleIndex >= 0 ) + { + setLocaleIndex( matchedLocaleIndex ); + } + else + { + cWarning() << "No available translation matched" << defaultTranslation.id() << defaultTranslation.locale(); + } +} + +void +Config::setCountryCode( const QString& countryCode ) +{ + m_countryCode = countryCode; + setLocaleIndex( Calamares::Locale::availableTranslations()->find( m_countryCode ) ); + + emit countryCodeChanged( m_countryCode ); +} + +void +Config::setLanguageIcon( const QString& languageIcon ) +{ + m_languageIcon = languageIcon; +} + +void +Config::setLocaleIndex( int index ) +{ + if ( index == m_localeIndex || index > Calamares::Locale::availableTranslations()->rowCount( QModelIndex() ) + || index < 0 ) + { + return; + } + + m_localeIndex = index; + + const auto& selectedTranslation = m_languages->locale( m_localeIndex ); + cDebug() << "Index" << index << "Selected locale" << selectedTranslation.id().name; + + QLocale::setDefault( selectedTranslation.locale() ); + const auto* branding = Calamares::Branding::instance(); + Calamares::installTranslator( selectedTranslation.id(), branding ? branding->translationsDirectory() : QString() ); + if ( Calamares::JobQueue::instance() && Calamares::JobQueue::instance()->globalStorage() ) + { + Calamares::Locale::insertGS( *Calamares::JobQueue::instance()->globalStorage(), + QStringLiteral( "LANG" ), + Calamares::translatorLocaleName().name ); + } + emit localeIndexChanged( m_localeIndex ); +} + +void +Config::setIsNextEnabled( bool isNextEnabled ) +{ + m_isNextEnabled = isNextEnabled; + emit isNextEnabledChanged( m_isNextEnabled ); +} + +void +Config::setDonateUrl( const QString& url ) +{ + m_donateUrl = url; + emit donateUrlChanged(); +} + +void +Config::setKnownIssuesUrl( const QString& url ) +{ + m_knownIssuesUrl = url; + emit knownIssuesUrlChanged(); +} + +void +Config::setReleaseNotesUrl( const QString& url ) +{ + m_releaseNotesUrl = url; + emit releaseNotesUrlChanged(); +} + +void +Config::setSupportUrl( const QString& url ) +{ + m_supportUrl = url; + emit supportUrlChanged(); +} + +QString +Config::aboutMessage() const +{ + return Calamares::aboutString(); +} + +QString +Config::genericWelcomeMessage() const +{ + QString message; + + const auto* settings = Calamares::Settings::instance(); + const auto* branding = Calamares::Branding::instance(); + const bool welcomeStyle = branding ? branding->welcomeStyleCalamares() : true; + + if ( settings ? settings->isSetupMode() : false ) + { + message = welcomeStyle ? tr( "

    Welcome to the Calamares setup program for %1

    " ) + : tr( "

    Welcome to %1 setup

    " ); + } + else + { + message = welcomeStyle ? tr( "

    Welcome to the Calamares installer for %1

    " ) + : tr( "

    Welcome to the %1 installer

    " ); + } + + return message; +} + +QString +Config::warningMessage() const +{ + return m_warningMessage; +} + +/** @brief Look up a URL for a button + * + * Looks up @p key in @p map; if it is a *boolean* value, then + * assume an old-style configuration, and fetch the string from + * the branding settings @p e. If it is a string, not a boolean, + * use it as-is. If not found, or a weird type, returns empty. + * + * This allows switching the showKnownIssuesUrl and similar settings + * in welcome.conf from a boolean (deferring to branding) to an + * actual string for immediate use. Empty strings, as well as + * "false" as a setting, will hide the buttons as before. + */ +static QString +jobOrBrandingSetting( Calamares::Branding::StringEntry e, const QVariantMap& map, const QString& key ) +{ + if ( !map.contains( key ) ) + { + return QString(); + } + auto v = map.value( key ); + if ( Calamares::typeOf( v ) == Calamares::BoolVariantType ) + { + return v.toBool() ? ( Calamares::Branding::instance()->string( e ) ) : QString(); + } + if ( Calamares::typeOf( v ) == Calamares::StringVariantType ) + { + return v.toString(); + } + + return QString(); +} + +static inline void +setLanguageIcon( Config* c, const QVariantMap& configurationMap ) +{ + QString language = Calamares::getString( configurationMap, "languageIcon" ); + if ( !language.isEmpty() ) + { + auto icon = Calamares::Branding::instance()->image( language, QSize( 48, 48 ) ); + if ( !icon.isNull() ) + { + c->setLanguageIcon( language ); + } + } +} + +static inline void +logGeoIPHandler( Calamares::GeoIP::Handler* handler ) +{ + if ( handler ) + { + cDebug() << Logger::SubEntry << "Obtained from" << handler->url() << " (" + << static_cast< int >( handler->type() ) << handler->selector() << ')'; + } +} + +static void +setCountry( Config* config, const QString& countryCode, Calamares::GeoIP::Handler* handler ) +{ + if ( countryCode.length() != 2 ) + { + cDebug() << "Unusable country code" << countryCode; + logGeoIPHandler( handler ); + return; + } + + auto c_l = Calamares::Locale::countryData( countryCode ); + if ( c_l.first == QLocale::Country::AnyCountry ) + { + cDebug() << "Unusable country code" << countryCode; + logGeoIPHandler( handler ); + return; + } + else + { + int r = Calamares::Locale::availableTranslations()->find( countryCode ); + if ( r < 0 ) + { + cDebug() << "Unusable country code" << countryCode << "(no suitable translation)"; + } + if ( ( r >= 0 ) && config ) + { + config->setCountryCode( countryCode ); + } + } +} + +static inline void +setGeoIP( Config* config, const QVariantMap& configurationMap ) +{ + bool ok = false; + QVariantMap geoip = Calamares::getSubMap( configurationMap, "geoip", ok ); + if ( ok ) + { + using FWString = QFutureWatcher< QString >; + + auto* handler = new Calamares::GeoIP::Handler( Calamares::getString( geoip, "style" ), + Calamares::getString( geoip, "url" ), + Calamares::getString( geoip, "selector" ) ); + if ( handler->type() != Calamares::GeoIP::Handler::Type::None ) + { + auto* future = new FWString(); + QObject::connect( future, + &FWString::finished, + [ config, future, handler ]() + { + QString countryResult = future->future().result(); + cDebug() << "GeoIP result for welcome=" << countryResult; + ::setCountry( config, countryResult, handler ); + future->deleteLater(); + delete handler; + } ); + future->setFuture( handler->queryRaw() ); + } + else + { + // Would not produce useful country code anyway. + delete handler; + } + } +} + +void +Config::setConfigurationMap( const QVariantMap& configurationMap ) +{ + using Calamares::Branding; + + setSupportUrl( jobOrBrandingSetting( Branding::SupportUrl, configurationMap, "showSupportUrl" ) ); + setKnownIssuesUrl( jobOrBrandingSetting( Branding::KnownIssuesUrl, configurationMap, "showKnownIssuesUrl" ) ); + setReleaseNotesUrl( jobOrBrandingSetting( Branding::ReleaseNotesUrl, configurationMap, "showReleaseNotesUrl" ) ); + setDonateUrl( jobOrBrandingSetting( Branding::DonateUrl, configurationMap, "showDonateUrl" ) ); + + ::setLanguageIcon( this, configurationMap ); + ::setGeoIP( this, configurationMap ); + + if ( configurationMap.contains( "requirements" ) + && Calamares::typeOf( configurationMap.value( "requirements" ) ) == Calamares::MapVariantType ) + { + m_requirementsChecker->setConfigurationMap( configurationMap.value( "requirements" ).toMap() ); + } + else + { + cWarning() << "no valid requirements map found in welcome " + "module configuration."; + } +} diff --git a/calamares/src/modules/welcome/Config.h b/calamares/src/modules/welcome/Config.h new file mode 100644 index 0000000..276f0cc --- /dev/null +++ b/calamares/src/modules/welcome/Config.h @@ -0,0 +1,143 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef WELCOME_CONFIG_H +#define WELCOME_CONFIG_H + +#include "checker/GeneralRequirements.h" +#include "locale/TranslationsModel.h" +#include "modulesystem/RequirementsModel.h" + +#include +#include +#include + +#include + +class Config : public QObject +{ + Q_OBJECT + /** @brief The languages available in Calamares. + * + * This is a list-model, with names and descriptions for the translations + * available to Calamares. + */ + Q_PROPERTY( Calamares::Locale::TranslationsModel* languagesModel READ languagesModel CONSTANT FINAL ) + /** @brief The requirements (from modules) and their checked-status + * + * The model grows rows over time as each module is checked and its + * requirements are taken into account. The model **as a whole** + * has properties *satisfiedRequirements* and *satisfiedMandatory* + * to say if all of the requirements held in the model have been + * satisfied. See the model documentation for details. + */ + Q_PROPERTY( Calamares::RequirementsModel* requirementsModel READ requirementsModel CONSTANT FINAL ) + /** @brief The requirements (from modules) that are **unsatisfied** + * + * This is the same as requirementsModel(), except filtered so + * that only those requirements that are not satisfied are exposed. + * Note that the type is different, so you should still use the + * requirementsModel() for overall status like *satisfiedMandatory*. + */ + Q_PROPERTY( QAbstractItemModel* unsatisfiedRequirements READ unsatisfiedRequirements CONSTANT FINAL ) + + Q_PROPERTY( QString languageIcon READ languageIcon CONSTANT FINAL ) + + Q_PROPERTY( QString countryCode MEMBER m_countryCode NOTIFY countryCodeChanged FINAL ) + Q_PROPERTY( int localeIndex READ localeIndex WRITE setLocaleIndex NOTIFY localeIndexChanged ) + + Q_PROPERTY( QString aboutMessage READ aboutMessage CONSTANT FINAL ) + Q_PROPERTY( QString genericWelcomeMessage MEMBER m_genericWelcomeMessage NOTIFY genericWelcomeMessageChanged FINAL ) + Q_PROPERTY( QString warningMessage READ warningMessage NOTIFY warningMessageChanged FINAL ) + + Q_PROPERTY( QString supportUrl MEMBER m_supportUrl NOTIFY supportUrlChanged FINAL ) + Q_PROPERTY( QString knownIssuesUrl MEMBER m_knownIssuesUrl NOTIFY knownIssuesUrlChanged FINAL ) + Q_PROPERTY( QString releaseNotesUrl MEMBER m_releaseNotesUrl NOTIFY releaseNotesUrlChanged FINAL ) + Q_PROPERTY( QString donateUrl MEMBER m_donateUrl NOTIFY donateUrlChanged FINAL ) + + Q_PROPERTY( bool isNextEnabled MEMBER m_isNextEnabled NOTIFY isNextEnabledChanged FINAL ) + +public: + Config( QObject* parent = nullptr ); + + void setConfigurationMap( const QVariantMap& ); + + void setCountryCode( const QString& countryCode ); + + QString languageIcon() const; + void setLanguageIcon( const QString& languageIcon ); + + void setIsNextEnabled( bool isNextEnabled ); + + int localeIndex() const { return m_localeIndex; } + void setLocaleIndex( int index ); + + QString supportUrl() const { return m_supportUrl; } + void setSupportUrl( const QString& url ); + + QString knownIssuesUrl() const { return m_knownIssuesUrl; } + void setKnownIssuesUrl( const QString& url ); + + QString releaseNotesUrl() const { return m_releaseNotesUrl; } + void setReleaseNotesUrl( const QString& url ); + + QString donateUrl() const { return m_donateUrl; } + void setDonateUrl( const QString& url ); + + QString aboutMessage() const; + QString genericWelcomeMessage() const; + QString warningMessage() const; + +public slots: + Calamares::Locale::TranslationsModel* languagesModel() const; + void retranslate(); + + ///@brief The **global** requirements model, from ModuleManager + Calamares::RequirementsModel* requirementsModel() const; + + QAbstractItemModel* unsatisfiedRequirements() const; + + /// @brief Check the general requirements + Calamares::RequirementsList checkRequirements() const { return m_requirementsChecker->checkRequirements(); } + +signals: + void countryCodeChanged( QString countryCode ); + void localeIndexChanged( int localeIndex ); + void isNextEnabledChanged( bool isNextEnabled ); + + void genericWelcomeMessageChanged( QString message ); + void warningMessageChanged( QString message ); + + void supportUrlChanged(); + void knownIssuesUrlChanged(); + void releaseNotesUrlChanged(); + void donateUrlChanged(); + +private: + void initLanguages(); + + Calamares::Locale::TranslationsModel* m_languages = nullptr; + std::unique_ptr< QSortFilterProxyModel > m_filtermodel; + std::unique_ptr< GeneralRequirements > m_requirementsChecker; + + QString m_languageIcon; + QString m_countryCode; + int m_localeIndex = 0; + bool m_isNextEnabled = false; + + QString m_genericWelcomeMessage; + QString m_warningMessage; + + QString m_supportUrl; + QString m_knownIssuesUrl; + QString m_releaseNotesUrl; + QString m_donateUrl; +}; + +#endif diff --git a/calamares/src/modules/welcome/Tests.cpp b/calamares/src/modules/welcome/Tests.cpp new file mode 100644 index 0000000..a33991c --- /dev/null +++ b/calamares/src/modules/welcome/Tests.cpp @@ -0,0 +1,166 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "Config.h" + +#include "Branding.h" +#include "Settings.h" +#include "network/Manager.h" +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Yaml.h" + +#include + +class WelcomeTests : public QObject +{ + Q_OBJECT +public: + WelcomeTests(); + ~WelcomeTests() override {} + +private Q_SLOTS: + void initTestCase(); + + void testOneUrl(); + void testUrls_data(); + void testUrls(); + + void testBadConfigDoesNotResetUrls(); +}; + +WelcomeTests::WelcomeTests() {} + +void +WelcomeTests::initTestCase() +{ + Logger::setupLogLevel( Logger::LOGDEBUG ); + cDebug() << "Welcome test started."; + + // Ensure we have a system object, expect it to be a "bogus" one + Calamares::System* system = Calamares::System::instance(); + QVERIFY( system ); + cDebug() << Logger::SubEntry << "System @" << Logger::Pointer( system ); + + const auto* settings = Calamares::Settings::instance(); + if ( !settings ) + { + (void)new Calamares::Settings( true ); + } +} + +void +WelcomeTests::testOneUrl() +{ + Config c; + + // BUILD_AS_TEST is the source-directory path + QString filename = QStringLiteral( "1a-checkinternet.conf" ); + QFileInfo fi( QString( "%1/tests/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( QFileInfo( fi ), &ok ); + QVERIFY( ok ); + QVERIFY( map.count() > 0 ); + QVERIFY( map.contains( "requirements" ) ); + + c.setConfigurationMap( map ); + QCOMPARE( Calamares::Network::Manager::getCheckInternetUrls().count(), 1 ); +} + +void +WelcomeTests::testUrls_data() +{ + QTest::addColumn< QString >( "filename" ); + QTest::addColumn< int >( "result" ); + + QTest::newRow( "one " ) << QString( "1a-checkinternet.conf" ) << 1; + QTest::newRow( "none " ) << QString( "1b-checkinternet.conf" ) << 0; + QTest::newRow( "blank" ) << QString( "1c-checkinternet.conf" ) << 1; + QTest::newRow( "bogus" ) << QString( "1d-checkinternet.conf" ) << 1; + QTest::newRow( "[] " ) << QString( "1e-checkinternet.conf" ) << 1; + QTest::newRow( "-3 " ) << QString( "1f-checkinternet.conf" ) << 3; + QTest::newRow( "[3] " ) << QString( "1g-checkinternet.conf" ) << 3; + QTest::newRow( "some " ) << QString( "1h-checkinternet.conf" ) << 3; +} + +void +WelcomeTests::testUrls() +{ + QFETCH( QString, filename ); + QFETCH( int, result ); + + Config c; + + // BUILD_AS_TEST is the source-directory path + QFileInfo fi( QString( "%1/tests/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + + Calamares::Network::Manager::setCheckHasInternetUrl( QVector< QUrl > {} ); + QCOMPARE( Calamares::Network::Manager::getCheckInternetUrls().count(), 0 ); + c.setConfigurationMap( map ); + QCOMPARE( Calamares::Network::Manager::getCheckInternetUrls().count(), result ); +} + +void +WelcomeTests::testBadConfigDoesNotResetUrls() +{ + Calamares::Network::Manager nam; + Calamares::Network::Manager::setCheckHasInternetUrl( QVector< QUrl > {} ); + QCOMPARE( nam.getCheckInternetUrls().count(), 0 ); + nam.setCheckHasInternetUrl( QVector< QUrl > { QUrl( "http://example.com" ), QUrl( "https://www.kde.org" ) } ); + QCOMPARE( nam.getCheckInternetUrls().count(), 2 ); + + Config c; + + // This is slightly surprising: if there is **no** requirements + // configuration, the list of check-URLs is left unchanged. + { + const QString filename = QStringLiteral( "1b-checkinternet.conf" ); // "none" + + // BUILD_AS_TEST is the source-directory path + QFileInfo fi( QString( "%1/tests/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + + c.setConfigurationMap( map ); + } + QCOMPARE( nam.getCheckInternetUrls().count(), 2 ); + + // But if the config contains a requirements entry, even if broken, + // the list is changed (to the default). + { + const QString filename = QStringLiteral( "1d-checkinternet.conf" ); // "bogus" + + // BUILD_AS_TEST is the source-directory path + QFileInfo fi( QString( "%1/tests/%2" ).arg( BUILD_AS_TEST, filename ) ); + QVERIFY( fi.exists() ); + + bool ok = false; + const auto map = Calamares::YAML::load( fi, &ok ); + QVERIFY( ok ); + + c.setConfigurationMap( map ); + } + QCOMPARE( nam.getCheckInternetUrls().count(), 1 ); +} + +QTEST_GUILESS_MAIN( WelcomeTests ) + +#include "utils/moc-warnings.h" + +#include "Tests.moc" diff --git a/calamares/src/modules/welcome/WelcomePage.cpp b/calamares/src/modules/welcome/WelcomePage.cpp new file mode 100644 index 0000000..30e3d6b --- /dev/null +++ b/calamares/src/modules/welcome/WelcomePage.cpp @@ -0,0 +1,227 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2015 Anke Boersma + * SPDX-FileCopyrightText: 2017-2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "WelcomePage.h" + +#include "checker/CheckerContainer.h" +#include "ui_WelcomePage.h" + +#include "Branding.h" +#include "CalamaresAbout.h" +#include "CalamaresVersion.h" +#include "Config.h" +#include "Settings.h" +#include "ViewManager.h" + +#include "modulesystem/ModuleManager.h" +#include "modulesystem/RequirementsModel.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/NamedEnum.h" +#include "utils/Retranslator.h" + +#include +#include +#include +#include +#include +#include +#include + +WelcomePage::WelcomePage( Config* config, QWidget* parent ) + : QWidget( parent ) + , ui( new Ui::WelcomePage ) + , m_checkingWidget( new CheckerContainer( config, this ) ) + , m_languages( nullptr ) + , m_conf( config ) +{ + using Branding = Calamares::Branding; + + const int defaultFontHeight = Calamares::defaultFontHeight(); + ui->setupUi( this ); + + // insert system-check widget below welcome text + const int welcome_text_idx = ui->verticalLayout->indexOf( ui->mainText ); + ui->verticalLayout->insertWidget( welcome_text_idx + 1, m_checkingWidget ); + + // insert optional logo banner image above welcome text + QString bannerPath = Branding::instance()->imagePath( Branding::ProductBanner ); + if ( !bannerPath.isEmpty() ) + { + // If the name is not empty, the file exists -- Branding checks that at startup + QPixmap bannerPixmap = QPixmap( bannerPath ); + if ( !bannerPixmap.isNull() ) + { + QLabel* bannerLabel = new QLabel; + bannerLabel->setPixmap( bannerPixmap ); + bannerLabel->setMinimumHeight( 64 ); + bannerLabel->setAlignment( Qt::AlignCenter ); + ui->aboveTextSpacer->changeSize( 20, defaultFontHeight ); // Shrink it down + ui->aboveTextSpacer->invalidate(); + ui->verticalLayout->insertSpacing( welcome_text_idx, defaultFontHeight ); + ui->verticalLayout->insertWidget( welcome_text_idx, bannerLabel ); + } + } + + initLanguages(); + + CALAMARES_RETRANSLATE_SLOT( &WelcomePage::retranslate ); + + connect( Calamares::ModuleManager::instance(), + &Calamares::ModuleManager::requirementsComplete, + m_checkingWidget, + &CheckerContainer::requirementsComplete ); + connect( Calamares::ModuleManager::instance()->requirementsModel(), + &Calamares::RequirementsModel::progressMessageChanged, + m_checkingWidget, + &CheckerContainer::requirementsProgress ); +} + +void +WelcomePage::init() +{ + //setup the url buttons + setupButton( WelcomePage::Button::Support, m_conf->supportUrl() ); + setupButton( WelcomePage::Button::KnownIssues, m_conf->knownIssuesUrl() ); + setupButton( WelcomePage::Button::ReleaseNotes, m_conf->releaseNotesUrl() ); + setupButton( WelcomePage::Button::Donate, m_conf->donateUrl() ); + + //language icon + auto icon = Calamares::Branding::instance()->image( m_conf->languageIcon(), QSize( 48, 48 ) ); + if ( !icon.isNull() ) + { + setLanguageIcon( icon ); + } +} + +void +WelcomePage::initLanguages() +{ + // Fill the list of translations + ui->languageWidget->clear(); + ui->languageWidget->setInsertPolicy( QComboBox::InsertAtBottom ); + + ui->languageWidget->setModel( m_conf->languagesModel() ); + ui->languageWidget->setItemDelegate( new LocaleTwoColumnDelegate( ui->languageWidget ) ); + + ui->languageWidget->setCurrentIndex( m_conf->localeIndex() ); + + connect( ui->languageWidget, + static_cast< void ( QComboBox::* )( int ) >( &QComboBox::currentIndexChanged ), + m_conf, + &Config::setLocaleIndex ); +} + +void +WelcomePage::setupButton( Button role, const QString& url ) +{ + QPushButton* button = nullptr; + Calamares::ImageType icon = Calamares::Information; + + switch ( role ) + { + case Button::Donate: + button = ui->donateButton; + icon = Calamares::Donate; + break; + case Button::KnownIssues: + button = ui->knownIssuesButton; + icon = Calamares::Bugs; + break; + case Button::ReleaseNotes: + button = ui->releaseNotesButton; + icon = Calamares::Release; + break; + case Button::Support: + button = ui->supportButton; + icon = Calamares::Help; + break; + } + if ( !button ) + { + cWarning() << "Unknown button role" << smash( role ); + return; + } + + if ( url.isEmpty() ) + { + button->hide(); + return; + } + + QUrl u( url ); + if ( u.isValid() ) + { + auto size = 2 * QSize( Calamares::defaultFontHeight(), Calamares::defaultFontHeight() ); + button->setIcon( Calamares::defaultPixmap( icon, Calamares::Original, size ) ); + connect( button, &QPushButton::clicked, [ u ]() { QDesktopServices::openUrl( u ); } ); + } + else + { + cWarning() << "Welcome button" << smash( role ) << "URL" << url << "is invalid."; + button->hide(); + } +} + +void +WelcomePage::focusInEvent( QFocusEvent* e ) +{ + if ( ui->languageWidget ) + { + ui->languageWidget->setFocus(); + } + e->accept(); +} + +bool +WelcomePage::verdict() const +{ + return m_checkingWidget->verdict(); +} + +void +WelcomePage::externallySelectedLanguage( int row ) +{ + if ( ( row >= 0 ) && ( row < ui->languageWidget->count() ) ) + { + ui->languageWidget->setCurrentIndex( row ); + } +} + +void +WelcomePage::setLanguageIcon( QPixmap i ) +{ + ui->languageIcon->setPixmap( i ); +} + +void +WelcomePage::retranslate() +{ + const QString message = m_conf->genericWelcomeMessage(); + + ui->mainText->setText( message.arg( Calamares::Branding::instance()->versionedName() ) ); + ui->retranslateUi( this ); + ui->supportButton->setText( + tr( "%1 Support", "@action" ).arg( Calamares::Branding::instance()->shortProductName() ) ); +} + +void +LocaleTwoColumnDelegate::paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const +{ + QStyledItemDelegate::paint( painter, option, index ); + option.widget->style()->drawItemText( + painter, + option.rect, + Qt::AlignRight | Qt::AlignVCenter, + option.palette, + false, + index.data( Calamares::Locale::TranslationsModel::EnglishLabelRole ).toString() ); +} diff --git a/calamares/src/modules/welcome/WelcomePage.h b/calamares/src/modules/welcome/WelcomePage.h new file mode 100644 index 0000000..163d13e --- /dev/null +++ b/calamares/src/modules/welcome/WelcomePage.h @@ -0,0 +1,83 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef WELCOMEPAGE_H +#define WELCOMEPAGE_H + +#include "locale/TranslationsModel.h" + +#include +#include + +namespace Ui +{ +class WelcomePage; +} // namespace Ui + +class CheckerContainer; +class Config; +class WelcomePage : public QWidget +{ + Q_OBJECT +public: + WelcomePage( Config* config, QWidget* parent = nullptr ); + + enum class Button + { + Support, + Donate, + KnownIssues, + ReleaseNotes + }; + + /// @brief Configure the button @p n, to open @p url + void setupButton( Button b, const QString& url ); + + /// @brief Set international language-selector icon + void setLanguageIcon( QPixmap ); + + /// @brief Results of requirements checking + bool verdict() const; + + /// @brief Change the language from an external source. + void externallySelectedLanguage( int row ); + + void init(); + +public slots: + void retranslate(); + +protected: + void focusInEvent( QFocusEvent* e ) override; //choose the child widget to focus + +private: + /// @brief Fill the list of languages with the available translations + void initLanguages(); + + Ui::WelcomePage* ui; + CheckerContainer* m_checkingWidget; + Calamares::Locale::TranslationsModel* m_languages; + + Config* m_conf; +}; + +/** @brief Delegate to display language information in two columns. + * + * Displays the native language name and the English language name. + */ +class LocaleTwoColumnDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const override; +}; + +#endif // WELCOMEPAGE_H diff --git a/calamares/src/modules/welcome/WelcomePage.ui b/calamares/src/modules/welcome/WelcomePage.ui new file mode 100644 index 0000000..0e3dcb0 --- /dev/null +++ b/calamares/src/modules/welcome/WelcomePage.ui @@ -0,0 +1,230 @@ + + + +SPDX-FileCopyrightText: 2014 Teo Mrnjavac <teo@kde.org> +SPDX-License-Identifier: GPL-3.0-or-later + + WelcomePage + + + + 0 + 0 + 593 + 400 + + + + Form + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 40 + + + + + + + + + 3 + 0 + + + + <Calamares welcome text> + + + Qt::AlignCenter + + + true + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 40 + 20 + + + + + + + + Select application and system language + + + + + + :/welcome/language-icon-48px.png + + + + + + + + 2 + 0 + + + + Select application and system language + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Open donations website + + + &Donate + + + true + + + + + + + Open help and support website + + + &Support + + + true + + + + + + + Open issues and bug-tracking website + + + &Known issues + + + true + + + + + + + Open release notes website + + + &Release notes + + + true + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + + + + + + diff --git a/calamares/src/modules/welcome/WelcomeViewStep.cpp b/calamares/src/modules/welcome/WelcomeViewStep.cpp new file mode 100644 index 0000000..f9c59eb --- /dev/null +++ b/calamares/src/modules/welcome/WelcomeViewStep.cpp @@ -0,0 +1,105 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "WelcomeViewStep.h" + +#include "Config.h" +#include "WelcomePage.h" + +#include "Branding.h" +#include "modulesystem/ModuleManager.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( WelcomeViewStepFactory, registerPlugin< WelcomeViewStep >(); ) + +WelcomeViewStep::WelcomeViewStep( QObject* parent ) + : Calamares::ViewStep( parent ) + , m_conf( new Config( this ) ) + , m_widget( new WelcomePage( m_conf ) ) +{ + connect( Calamares::ModuleManager::instance(), + &Calamares::ModuleManager::requirementsComplete, + this, + &WelcomeViewStep::nextStatusChanged ); + connect( m_conf, &Config::localeIndexChanged, m_widget, &WelcomePage::externallySelectedLanguage ); +} + +WelcomeViewStep::~WelcomeViewStep() +{ + if ( m_widget && m_widget->parent() == nullptr ) + { + m_widget->deleteLater(); + } +} + +QString +WelcomeViewStep::prettyName() const +{ + return tr( "Welcome", "@title" ); +} + + +QWidget* +WelcomeViewStep::widget() +{ + return m_widget; +} + + +bool +WelcomeViewStep::isNextEnabled() const +{ + return m_widget->verdict(); +} + + +bool +WelcomeViewStep::isBackEnabled() const +{ + return false; +} + + +bool +WelcomeViewStep::isAtBeginning() const +{ + return true; +} + + +bool +WelcomeViewStep::isAtEnd() const +{ + return true; +} + + +Calamares::JobList +WelcomeViewStep::jobs() const +{ + return Calamares::JobList(); +} + + +void +WelcomeViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_conf->setConfigurationMap( configurationMap ); + + //here init the qml or qwidgets needed bits + m_widget->init(); +} + +Calamares::RequirementsList +WelcomeViewStep::checkRequirements() +{ + return m_conf->checkRequirements(); +} diff --git a/calamares/src/modules/welcome/WelcomeViewStep.h b/calamares/src/modules/welcome/WelcomeViewStep.h new file mode 100644 index 0000000..eeb9328 --- /dev/null +++ b/calamares/src/modules/welcome/WelcomeViewStep.h @@ -0,0 +1,73 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef WELCOMEVIEWSTEP_H +#define WELCOMEVIEWSTEP_H + + +#include "DllMacro.h" +#include "modulesystem/Requirement.h" +#include "utils/PluginFactory.h" +#include "viewpages/ViewStep.h" + +#include +#include + +class WelcomePage; +class GeneralRequirements; +class Config; + +namespace Calamares +{ +namespace GeoIP +{ +class Handler; +} // namespace GeoIP +} // namespace Calamares + +class PLUGINDLLEXPORT WelcomeViewStep : public Calamares::ViewStep +{ + Q_OBJECT + +public: + explicit WelcomeViewStep( QObject* parent = nullptr ); + ~WelcomeViewStep() override; + + QString prettyName() const override; + + QWidget* widget() override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + /** @brief Sets the country that Calamares is running in. + * + * This (ideally) sets up language and locale settings that are right for + * the given 2-letter country code. Uses the handler's information (if + * given) for error reporting. + */ + void setCountry( const QString&, Calamares::GeoIP::Handler* handler ); + + Calamares::RequirementsList checkRequirements() override; + +private: + Config* m_conf; + WelcomePage* m_widget; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( WelcomeViewStepFactory ) + +#endif // WELCOMEVIEWSTEP_H diff --git a/calamares/src/modules/welcome/checker/CheckerContainer.cpp b/calamares/src/modules/welcome/checker/CheckerContainer.cpp new file mode 100644 index 0000000..4b56987 --- /dev/null +++ b/calamares/src/modules/welcome/checker/CheckerContainer.cpp @@ -0,0 +1,98 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-FileCopyrightText: 2017 Gabriel Craciunescu + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* Based on code extracted from RequirementsChecker.cpp */ + +#include "CheckerContainer.h" + +#include "ResultsListWidget.h" + +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "widgets/WaitingWidget.h" + +#include + +CheckerContainer::CheckerContainer( Config* config, QWidget* parent ) + : QWidget( parent ) + , m_waitingWidget( new WaitingWidget( QString(), this ) ) + , m_checkerWidget( nullptr ) + , m_verdict( false ) + , m_config( config ) +{ + QBoxLayout* mainLayout = new QHBoxLayout; + setLayout( mainLayout ); + Calamares::unmarginLayout( mainLayout ); + + mainLayout->addWidget( m_waitingWidget ); + CALAMARES_RETRANSLATE( if ( m_waitingWidget ) + m_waitingWidget->setText( tr( "Gathering system information…" ) ); ); +} + +CheckerContainer::~CheckerContainer() +{ + delete m_waitingWidget; + delete m_checkerWidget; +} + +void +CheckerContainer::requirementsComplete( bool ok ) +{ + if ( !ok ) + { + auto& model = *( m_config->requirementsModel() ); + cDebug() << "Requirements not satisfied" << model.count() << "entries:"; + for ( int i = 0; i < model.count(); ++i ) + { + auto index = model.index( i ); + const bool satisfied = model.data( index, Calamares::RequirementsModel::Satisfied ).toBool(); + const bool mandatory = model.data( index, Calamares::RequirementsModel::Mandatory ).toBool(); + if ( !satisfied ) + { + cDebug() << Logger::SubEntry << i << model.data( index, Calamares::RequirementsModel::Name ).toString() + << "not-satisfied" + << "mandatory?" << mandatory; + } + } + } + + if ( m_waitingWidget ) + { + layout()->removeWidget( m_waitingWidget ); + m_waitingWidget->deleteLater(); + m_waitingWidget = nullptr; // Don't delete in destructor + } + if ( !m_checkerWidget ) + { + m_checkerWidget = new ResultsListWidget( m_config, this ); + m_checkerWidget->setObjectName( "requirementsChecker" ); + layout()->addWidget( m_checkerWidget ); + } + m_checkerWidget->requirementsComplete(); + + m_verdict = ok; +} + +void +CheckerContainer::requirementsProgress( const QString& message ) +{ + if ( m_waitingWidget ) + { + m_waitingWidget->setText( message ); + } +} + +bool +CheckerContainer::verdict() const +{ + return m_verdict; +} diff --git a/calamares/src/modules/welcome/checker/CheckerContainer.h b/calamares/src/modules/welcome/checker/CheckerContainer.h new file mode 100644 index 0000000..7846f70 --- /dev/null +++ b/calamares/src/modules/welcome/checker/CheckerContainer.h @@ -0,0 +1,56 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-FileCopyrightText: 2017 Gabriel Craciunescu + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* Based on code extracted from RequirementsChecker.cpp */ + +#ifndef CHECKERCONTAINER_H +#define CHECKERCONTAINER_H + +#include "Config.h" + +#include + +class ResultsListWidget; +class WaitingWidget; + +/** + * A widget that collects requirements results; until the results are + * all in, displays a spinner / waiting widget. Then it switches to + * a (list) diplay of the results, plus some explanation of the + * overall state of the entire list of results. + */ + +class CheckerContainer : public QWidget +{ + Q_OBJECT +public: + explicit CheckerContainer( Config* config, QWidget* parent = nullptr ); + ~CheckerContainer() override; + + bool verdict() const; + +public slots: + /** @brief All the requirements are complete, switch to list view */ + void requirementsComplete( bool ); + + void requirementsProgress( const QString& message ); + +protected: + WaitingWidget* m_waitingWidget; + ResultsListWidget* m_checkerWidget; + + bool m_verdict; + +private: + Config* m_config = nullptr; +}; + +#endif diff --git a/calamares/src/modules/welcome/checker/GeneralRequirements.cpp b/calamares/src/modules/welcome/checker/GeneralRequirements.cpp new file mode 100644 index 0000000..693f812 --- /dev/null +++ b/calamares/src/modules/welcome/checker/GeneralRequirements.cpp @@ -0,0 +1,517 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017-2018 2020, Adriaan de Groot + * SPDX-FileCopyrightText: 2017 Gabriel Craciunescu + * SPDX-FileCopyrightText: 2019 Collabora Ltd + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "GeneralRequirements.h" + +#include "CheckerContainer.h" +#include "partman_devices.h" + +#include "CalamaresVersion.h" // For development-or-not +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" +#include "compat/Variant.h" +#include "modulesystem/Requirement.h" +#include "network/Manager.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "utils/System.h" +#include "utils/Units.h" +#include "utils/Variant.h" +#include "widgets/WaitingWidget.h" + +#include +#include +#include +#include +#include +#include +#include + +#include //geteuid + +GeneralRequirements::GeneralRequirements( QObject* parent ) + : QObject( parent ) + , m_requiredStorageGiB( -1 ) + , m_requiredRamGiB( -1 ) +{ +} + +static QSize +biggestSingleScreen() +{ + QSize s; + for ( const auto* screen : QGuiApplication::screens() ) + { + QSize thisScreen = screen->availableSize(); + if ( !s.isValid() || ( s.width() * s.height() < thisScreen.width() * thisScreen.height() ) ) + { + s = thisScreen; + } + } + return s; +} + +/** @brief Distinguish has-not-been-checked-at-all from false. + * + */ +struct MaybeChecked +{ + bool hasBeenChecked = false; + bool value = false; + + MaybeChecked& operator=( bool b ) + { + hasBeenChecked = true; + value = b; + return *this; + } + + operator bool() const { return value; } +}; + +QDebug& +operator<<( QDebug& s, const MaybeChecked& c ) +{ + if ( c.hasBeenChecked ) + { + s << c.value; + } + else + { + s << "unchecked"; + } + return s; +} + +Calamares::RequirementsList +GeneralRequirements::checkRequirements() +{ + QSize availableSize = biggestSingleScreen(); + + MaybeChecked enoughStorage; + MaybeChecked enoughRam; + MaybeChecked hasPower; + MaybeChecked hasInternet; + MaybeChecked isRoot; + bool enoughScreen = availableSize.isValid() && ( availableSize.width() >= Calamares::windowMinimumWidth ) + && ( availableSize.height() >= Calamares::windowMinimumHeight ); + + qint64 requiredStorageB = Calamares::GiBtoBytes( m_requiredStorageGiB ); + if ( m_entriesToCheck.contains( "storage" ) ) + { + enoughStorage = checkEnoughStorage( requiredStorageB ); + } + + qint64 requiredRamB = Calamares::GiBtoBytes( m_requiredRamGiB ); + if ( m_entriesToCheck.contains( "ram" ) ) + { + enoughRam = checkEnoughRam( requiredRamB ); + } + + if ( m_entriesToCheck.contains( "power" ) ) + { + hasPower = checkHasPower(); + } + + if ( m_entriesToCheck.contains( "internet" ) ) + { + hasInternet = checkHasInternet(); + } + + if ( m_entriesToCheck.contains( "root" ) ) + { + isRoot = checkIsRoot(); + } + + using TNum = Logger::DebugRow< const char*, qint64 >; + using TR = Logger::DebugRow< const char*, MaybeChecked >; + // clang-format off + cDebug() << "GeneralRequirements output:" + << TNum( "storage", requiredStorageB ) + << TR( "enoughStorage", enoughStorage ) + << TNum( "RAM", requiredRamB ) + << TR( "enoughRam", enoughRam ) + << TR( "hasPower", hasPower ) + << TR( "hasInternet", hasInternet ) + << TR( "isRoot", isRoot ); + // clang-format on + + Calamares::RequirementsList checkEntries; + foreach ( const QString& entry, m_entriesToCheck ) + { + const bool required = m_entriesToRequire.contains( entry ); + if ( entry == "storage" ) + { + checkEntries.append( + { entry, + [ req = m_requiredStorageGiB ] + { + return tr( "Please ensure the system has at least %1 GiB available drive space." ).arg( req ) + + QStringLiteral( "

    " ) + + tr( "Available drive space is all of the hard disks and SSDs connected to the system." ); + }, + [ req = m_requiredStorageGiB ] + { return tr( "There is not enough drive space. At least %1 GiB is required." ).arg( req ); }, + enoughStorage, + required } ); + } + else if ( entry == "ram" ) + { + checkEntries.append( + { entry, + [ req = m_requiredRamGiB ] { return tr( "has at least %1 GiB working memory" ).arg( req ); }, + [ req = m_requiredRamGiB ] { + return tr( "The system does not have enough working memory. At least %1 GiB is required." ) + .arg( req ); + }, + enoughRam, + required } ); + } + else if ( entry == "power" ) + { + checkEntries.append( { entry, + [] { return tr( "is plugged in to a power source" ); }, + [] { return tr( "The system is not plugged in to a power source." ); }, + hasPower, + required } ); + } + else if ( entry == "internet" ) + { + checkEntries.append( { entry, + [] { return tr( "is connected to the Internet" ); }, + [] { return tr( "The system is not connected to the Internet." ); }, + hasInternet, + required } ); + } + else if ( entry == "root" ) + { + checkEntries.append( { entry, + [] { return tr( "is running the installer as an administrator (root)" ); }, + [] + { + return Calamares::Settings::instance()->isSetupMode() + ? tr( "The setup program is not running with administrator rights." ) + : tr( "The installer is not running with administrator rights." ); + }, + isRoot, + required } ); + } + else if ( entry == "screen" ) + { + checkEntries.append( { entry, + [] { return tr( "has a screen large enough to show the whole installer" ); }, + [] + { + return Calamares::Settings::instance()->isSetupMode() + ? tr( "The screen is too small to display the setup program." ) + : tr( "The screen is too small to display the installer." ); + }, + enoughScreen, + required } ); + } +#ifdef CALAMARES_VERSION_RC + if ( entry == "false" ) + { + checkEntries.append( { entry, + [] { return tr( "is always false" ); }, + [] { return tr( "The computer says no." ); }, + false, + required } ); + } + if ( entry == "slow-false" ) + { + sleep( 3 ); + checkEntries.append( { entry, + [] { return tr( "is always false (slowly)" ); }, + [] { return tr( "The computer says no (slowly)." ); }, + false, + required } ); + } + if ( entry == "true" ) + { + checkEntries.append( { entry, + [] { return tr( "is always true" ); }, + [] { return tr( "The computer says yes." ); }, + true, + required } ); + } + if ( entry == "slow-true" ) + { + sleep( 3 ); + checkEntries.append( { entry, + [] { return tr( "is always true (slowly)" ); }, + [] { return tr( "The computer says yes (slowly)." ); }, + true, + required } ); + } + if ( entry == "snark" ) + { + static unsigned int snark_count = 0; + checkEntries.append( { entry, + [] { return tr( "is checked three times." ); }, + [] + { + return tr( "The snark has not been checked three times.", + "The (some mythological beast) has not been checked three times." ); + }, + ++snark_count > 3, + required } ); + } +#endif + } + return checkEntries; +} + +/** @brief Loads the check-internet URLs + * + * There may be zero or one or more URLs specified; returns + * @c true if the configuration is incomplete or damaged in some way. + */ +static bool +getCheckInternetUrls( const QVariantMap& configurationMap ) +{ + const QString exampleUrl = QStringLiteral( "http://example.com" ); + + bool incomplete = false; + QStringList checkInternetSetting = Calamares::getStringList( configurationMap, "internetCheckUrl" ); + if ( !checkInternetSetting.isEmpty() ) + { + QVector< QUrl > urls; + for ( const auto& urlString : std::as_const( checkInternetSetting ) ) + { + QUrl url( urlString.trimmed() ); + if ( url.isValid() ) + { + urls.append( url ); + } + else + { + cWarning() << "GeneralRequirements entry 'internetCheckUrl' in welcome.conf contains invalid" + << urlString; + } + } + + if ( urls.empty() ) + { + cWarning() << "GeneralRequirements entry 'internetCheckUrl' contains no valid URLs, " + << "reverting to default (" << exampleUrl << ")."; + Calamares::Network::Manager::setCheckHasInternetUrl( QUrl( exampleUrl ) ); + incomplete = true; + } + else + { + Calamares::Network::Manager::setCheckHasInternetUrl( urls ); + } + } + else + { + cWarning() << "GeneralRequirements entry 'internetCheckUrl' is undefined in welcome.conf, " + "reverting to default (" + << exampleUrl << ")."; + Calamares::Network::Manager::setCheckHasInternetUrl( QUrl( exampleUrl ) ); + incomplete = true; + } + return incomplete; +} + +void +GeneralRequirements::setConfigurationMap( const QVariantMap& configurationMap ) +{ + bool incompleteConfiguration = false; + + if ( configurationMap.contains( "check" ) + && Calamares::typeOf( configurationMap.value( "check" ) ) == Calamares::ListVariantType ) + { + m_entriesToCheck.clear(); + m_entriesToCheck.append( configurationMap.value( "check" ).toStringList() ); + } + else + { + cWarning() << "GeneralRequirements entry 'check' is incomplete."; + incompleteConfiguration = true; + } + + if ( configurationMap.contains( "required" ) + && Calamares::typeOf( configurationMap.value( "required" ) ) == Calamares::ListVariantType ) + { + m_entriesToRequire.clear(); + m_entriesToRequire.append( configurationMap.value( "required" ).toStringList() ); + } + else + { + cWarning() << "GeneralRequirements entry 'required' is incomplete."; + incompleteConfiguration = true; + } + +#ifdef WITHOUT_LIBPARTED + if ( m_entriesToCheck.contains( "storage" ) || m_entriesToRequire.contains( "storage" ) ) + { + // Warn, but also drop the required bit because otherwise installation + // will be impossible (because the check always returns false). + cWarning() << "GeneralRequirements checks 'storage' but libparted is disabled."; + m_entriesToCheck.removeAll( "storage" ); + m_entriesToRequire.removeAll( "storage" ); + } +#endif + + // Help out with consistency, but don't fix + for ( const auto& r : m_entriesToRequire ) + { + if ( !m_entriesToCheck.contains( r ) ) + { + cWarning() << "GeneralRequirements requires" << r << "but does not check it."; + } + } + + if ( configurationMap.contains( "requiredStorage" ) + && ( Calamares::typeOf( configurationMap.value( "requiredStorage" ) ) == Calamares::DoubleVariantType + || Calamares::typeOf( configurationMap.value( "requiredStorage" ) ) == Calamares::LongLongVariantType ) ) + { + bool ok = false; + m_requiredStorageGiB = configurationMap.value( "requiredStorage" ).toDouble( &ok ); + if ( !ok ) + { + cWarning() << "GeneralRequirements entry 'requiredStorage' is invalid."; + m_requiredStorageGiB = 3.; + } + + Calamares::JobQueue::instance()->globalStorage()->insert( "requiredStorageGiB", m_requiredStorageGiB ); + } + else + { + cWarning() << "GeneralRequirements entry 'requiredStorage' is missing."; + m_requiredStorageGiB = 3.; + incompleteConfiguration = true; + } + + if ( configurationMap.contains( "requiredRam" ) + && ( Calamares::typeOf( configurationMap.value( "requiredRam" ) ) == Calamares::DoubleVariantType + || Calamares::typeOf( configurationMap.value( "requiredRam" ) ) == Calamares::LongLongVariantType ) ) + { + bool ok = false; + m_requiredRamGiB = configurationMap.value( "requiredRam" ).toDouble( &ok ); + if ( !ok ) + { + cWarning() << "GeneralRequirements entry 'requiredRam' is invalid."; + m_requiredRamGiB = 1.; + incompleteConfiguration = true; + } + } + else + { + cWarning() << "GeneralRequirements entry 'requiredRam' is missing."; + m_requiredRamGiB = 1.; + incompleteConfiguration = true; + } + + incompleteConfiguration |= getCheckInternetUrls( configurationMap ); + + if ( incompleteConfiguration ) + { + cWarning() << "GeneralRequirements configuration map:" << Logger::DebugMap( configurationMap ); + } +} + +bool +GeneralRequirements::checkEnoughStorage( qint64 requiredSpace ) +{ +#ifdef WITHOUT_LIBPARTED + Q_UNUSED( requiredSpace ) + cWarning() << "GeneralRequirements is configured without libparted."; + return false; +#else + return check_big_enough( requiredSpace ); +#endif +} + +bool +GeneralRequirements::checkEnoughRam( qint64 requiredRam ) +{ + // Ignore the guesstimate-factor; we get an under-estimate + // which is probably the usable RAM for programs. + quint64 availableRam = Calamares::System::instance()->getTotalMemoryB().first; + return double( availableRam ) >= double( requiredRam ) * 0.95; // cast to silence 64-bit-int conversion to double +} + +bool +GeneralRequirements::checkBatteryExists() +{ + const QFileInfo basePath( "/sys/class/power_supply" ); + + if ( !( basePath.exists() && basePath.isDir() ) ) + { + return false; + } + + QDir baseDir( basePath.absoluteFilePath() ); + const auto entries = baseDir.entryList( QDir::AllDirs | QDir::Readable | QDir::NoDotAndDotDot ); + for ( const auto& item : entries ) + { + QFileInfo typePath( baseDir.absoluteFilePath( QString( "%1/type" ).arg( item ) ) ); + QFile typeFile( typePath.absoluteFilePath() ); + if ( typeFile.open( QIODevice::ReadOnly | QIODevice::Text ) ) + { + if ( typeFile.readAll().startsWith( "Battery" ) ) + { + return true; + } + } + } + + return false; +} + +bool +GeneralRequirements::checkHasPower() +{ + const QString UPOWER_SVC_NAME( "org.freedesktop.UPower" ); + const QString UPOWER_INTF_NAME( "org.freedesktop.UPower" ); + const QString UPOWER_PATH( "/org/freedesktop/UPower" ); + + if ( !checkBatteryExists() ) + { + return true; + } + + cDebug() << "A battery exists, checking for mains power."; + QDBusInterface upowerIntf( UPOWER_SVC_NAME, UPOWER_PATH, UPOWER_INTF_NAME, QDBusConnection::systemBus() ); + + bool onBattery = upowerIntf.property( "OnBattery" ).toBool(); + + if ( !upowerIntf.isValid() ) + { + // We can't talk to upower but we're obviously up and running + // so I guess we got that going for us, which is nice... + return true; + } + + // If a battery exists but we're not using it, means we got mains + // power. + return !onBattery; +} + +bool +GeneralRequirements::checkHasInternet() +{ + Calamares::Network::Manager nam; + bool hasInternet = nam.checkHasInternet(); + Calamares::JobQueue::instance()->globalStorage()->insert( "hasInternet", hasInternet ); + return hasInternet; +} + +bool +GeneralRequirements::checkIsRoot() +{ + return !geteuid(); +} diff --git a/calamares/src/modules/welcome/checker/GeneralRequirements.h b/calamares/src/modules/welcome/checker/GeneralRequirements.h new file mode 100644 index 0000000..b6646da --- /dev/null +++ b/calamares/src/modules/welcome/checker/GeneralRequirements.h @@ -0,0 +1,44 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2017 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef GENERALREQUIREMENTS_H +#define GENERALREQUIREMENTS_H + +#include +#include + +#include "modulesystem/Requirement.h" + +class GeneralRequirements : public QObject +{ + Q_OBJECT +public: + explicit GeneralRequirements( QObject* parent = nullptr ); + + void setConfigurationMap( const QVariantMap& configurationMap ); + + Calamares::RequirementsList checkRequirements(); + +private: + QStringList m_entriesToCheck; + QStringList m_entriesToRequire; + + bool checkEnoughStorage( qint64 requiredSpace ); + bool checkEnoughRam( qint64 requiredRam ); + bool checkBatteryExists(); + bool checkHasPower(); + bool checkHasInternet(); + bool checkIsRoot(); + + qreal m_requiredStorageGiB; + qreal m_requiredRamGiB; +}; + +#endif // REQUIREMENTSCHECKER_H diff --git a/calamares/src/modules/welcome/checker/ResultDelegate.cpp b/calamares/src/modules/welcome/checker/ResultDelegate.cpp new file mode 100644 index 0000000..ae74a36 --- /dev/null +++ b/calamares/src/modules/welcome/checker/ResultDelegate.cpp @@ -0,0 +1,102 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ResultDelegate.h" + +#include "modulesystem/RequirementsModel.h" +#include "utils/Gui.h" + +#include +#include + +static constexpr int const item_margin = 8; +static inline int +item_fontsize() +{ + return Calamares::defaultFontSize() + 4; +} + +static void +paintRequirement( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index, int role ) +{ + const auto fontsize = item_fontsize(); + + QRect textRect = option.rect.adjusted( item_margin, item_margin, -item_margin, -item_margin ); + QFont font = qApp->font(); + font.setPointSize( fontsize ); + font.setBold( false ); + painter->setFont( font ); + + Calamares::ImageType statusImage = Calamares::StatusOk; + + painter->setPen( option.palette.text().color() ); + if ( index.data( Calamares::RequirementsModel::Satisfied ).toBool() ) + { + painter->fillRect( textRect, option.palette.window().color() ); + } + else + { + if ( index.data( Calamares::RequirementsModel::Mandatory ).toBool() ) + { + QColor bgColor = option.palette.window().color(); + bgColor.setHsv( 0, 64, bgColor.value() ); + painter->fillRect( option.rect, bgColor ); + statusImage = Calamares::StatusError; + } + else + { + QColor bgColor = option.palette.window().color(); + bgColor.setHsv( 60, 64, bgColor.value() ); + painter->fillRect( option.rect, bgColor ); + statusImage = Calamares::StatusWarning; + } + } + + auto image + = Calamares::defaultPixmap( statusImage, Calamares::Original, QSize( 2 * fontsize, 2 * fontsize ) ).toImage(); + painter->drawImage( textRect.topLeft(), image ); + + // Leave space for that image (already drawn) + textRect.moveLeft( 2 * fontsize + 2 * item_margin ); + painter->drawText( textRect, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, index.data( role ).toString() ); +} + +QSize +ResultDelegate::sizeHint( const QStyleOptionViewItem& option, const QModelIndex& index ) const +{ + if ( !index.isValid() ) + { + return option.rect.size(); + } + + QFont font = qApp->font(); + + font.setPointSize( item_fontsize() ); + QFontMetrics fm( font ); + + const int height = fm.height() + 2 * item_margin; + int textwidth = fm.boundingRect( index.data( Calamares::RequirementsModel::NegatedText ).toString() ).width(); + + return QSize( qMax( option.rect.width(), textwidth ), height ); +} + +void +ResultDelegate::paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const +{ + QStyleOptionViewItem opt = option; + + painter->save(); + + initStyleOption( &opt, index ); + opt.text.clear(); + + paintRequirement( painter, opt, index, m_textRole ); + + painter->restore(); +} diff --git a/calamares/src/modules/welcome/checker/ResultDelegate.h b/calamares/src/modules/welcome/checker/ResultDelegate.h new file mode 100644 index 0000000..db77050 --- /dev/null +++ b/calamares/src/modules/welcome/checker/ResultDelegate.h @@ -0,0 +1,37 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2022 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef WELCOME_CHECKER_RESULTDELEGATE_HH +#define WELCOME_CHECKER_RESULTDELEGATE_HH + +#include + +#include "modulesystem/RequirementsModel.h" + +/** + * @brief Class for drawing (un)satisfied requirements + */ +class ResultDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + ResultDelegate( QObject* parent, Calamares::RequirementsModel::Roles text ) + : QStyledItemDelegate( parent ) + , m_textRole( text ) + { + } + +protected: + QSize sizeHint( const QStyleOptionViewItem& option, const QModelIndex& index ) const override; + void paint( QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index ) const override; + + int m_textRole = Calamares::RequirementsModel::Name; +}; + +#endif // PROGRESSTREEDELEGATE_H diff --git a/calamares/src/modules/welcome/checker/ResultsListWidget.cpp b/calamares/src/modules/welcome/checker/ResultsListWidget.cpp new file mode 100644 index 0000000..92e6668 --- /dev/null +++ b/calamares/src/modules/welcome/checker/ResultsListWidget.cpp @@ -0,0 +1,121 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ResultsListWidget.h" + +#include "ResultDelegate.h" + +#include "Branding.h" +#include "Settings.h" +#include "utils/Gui.h" +#include "utils/Logger.h" +#include "utils/Retranslator.h" +#include "widgets/FixedAspectRatioLabel.h" +#include "widgets/WaitingWidget.h" + +#include +#include +#include +#include +#include +#include + +ResultsListWidget::ResultsListWidget( Config* config, QWidget* parent ) + : QWidget( parent ) + , m_config( config ) +{ + setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); + + auto mainLayout = new QVBoxLayout; + setLayout( mainLayout ); + + QHBoxLayout* explanationLayout = new QHBoxLayout; + m_explanation = new QLabel( m_config->warningMessage() ); + m_explanation->setWordWrap( true ); + m_explanation->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Preferred ); + m_explanation->setOpenExternalLinks( false ); + m_explanation->setObjectName( "resultsExplanation" ); + explanationLayout->addWidget( m_explanation ); + m_countdown = new CountdownWaitingWidget; + m_countdown->setToolTip( tr( "Checking requirements again in a few seconds…" ) ); + m_countdown->start(); + explanationLayout->addWidget( m_countdown ); + + mainLayout->addLayout( explanationLayout ); + mainLayout->addSpacing( Calamares::defaultFontHeight() / 2 ); + + auto* listview = new QListView( this ); + listview->setSelectionMode( QAbstractItemView::NoSelection ); + listview->setDragDropMode( QAbstractItemView::NoDragDrop ); + listview->setAcceptDrops( false ); + listview->setItemDelegate( new ResultDelegate( this, Calamares::RequirementsModel::NegatedText ) ); + listview->setModel( config->unsatisfiedRequirements() ); + m_centralWidget = listview; + m_centralLayout = mainLayout; + + mainLayout->addWidget( listview ); + mainLayout->addStretch(); + + connect( config, &Config::warningMessageChanged, m_explanation, &QLabel::setText ); +} + +void +ResultsListWidget::requirementsComplete() +{ + // Check that the satisfaction of the requirements: + // - if everything is satisfied, show the welcome image + // - otherwise, if all the mandatory ones are satisfied, + // we won't be re-checking (see ModuleManager::checkRequirements) + // so hide the countdown, + // - otherwise we have unsatisfied mandatory requirements, + // so keep the countdown and the list of problems. + const bool requirementsSatisfied = m_config->requirementsModel()->satisfiedRequirements(); + const bool mandatoryRequirementsSatisfied = m_config->requirementsModel()->satisfiedMandatory(); + + if ( mandatoryRequirementsSatisfied ) + { + m_countdown->stop(); + m_countdown->hide(); + } + if ( requirementsSatisfied ) + { + delete m_centralWidget; + m_centralWidget = nullptr; + + if ( !Calamares::Branding::instance()->imagePath( Calamares::Branding::ProductWelcome ).isEmpty() ) + { + QPixmap theImage + = QPixmap( Calamares::Branding::instance()->imagePath( Calamares::Branding::ProductWelcome ) ); + if ( !theImage.isNull() ) + { + QLabel* imageLabel; + if ( Calamares::Branding::instance()->welcomeExpandingLogo() ) + { + FixedAspectRatioLabel* p = new FixedAspectRatioLabel; + p->setPixmap( theImage ); + imageLabel = p; + } + else + { + imageLabel = new QLabel; + imageLabel->setPixmap( theImage ); + } + + imageLabel->setContentsMargins( 4, Calamares::defaultFontHeight() * 3 / 4, 4, 4 ); + imageLabel->setAlignment( Qt::AlignCenter ); + imageLabel->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ); + imageLabel->setObjectName( "welcomeLogo" ); + // This specifically isn't assigned to m_centralWidget + m_centralLayout->addWidget( imageLabel ); + } + } + m_explanation->setAlignment( Qt::AlignCenter ); + } +} diff --git a/calamares/src/modules/welcome/checker/ResultsListWidget.h b/calamares/src/modules/welcome/checker/ResultsListWidget.h new file mode 100644 index 0000000..1f2f630 --- /dev/null +++ b/calamares/src/modules/welcome/checker/ResultsListWidget.h @@ -0,0 +1,46 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef CHECKER_RESULTSLISTWIDGET_H +#define CHECKER_RESULTSLISTWIDGET_H + +#include "Config.h" + +#include + +class CountdownWaitingWidget; + +class QBoxLayout; +class QLabel; + +class ResultsListWidget : public QWidget +{ + Q_OBJECT +public: + explicit ResultsListWidget( Config* config, QWidget* parent ); + + /// @brief The model of requirements has finished a round of checking + void requirementsComplete(); + +private: + Config* m_config = nullptr; + + // UI parts, which need updating when the model changes + QLabel* m_explanation = nullptr; + CountdownWaitingWidget* m_countdown = nullptr; + // There is a central widget, which can be: + // - a list widget showing failed requirements + // - nullptr (when displaying a pretty label for language / splash purposes) + // it is placed in the central layout. + QWidget* m_centralWidget = nullptr; + QBoxLayout* m_centralLayout = nullptr; +}; + +#endif // CHECKER_RESULTSLISTWIDGET_H diff --git a/calamares/src/modules/welcome/checker/partman_devices.c b/calamares/src/modules/welcome/checker/partman_devices.c new file mode 100644 index 0000000..7cde7a8 --- /dev/null +++ b/calamares/src/modules/welcome/checker/partman_devices.c @@ -0,0 +1,143 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Based on parted_devices.c, from partman-base. + * + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* This is third-party ancient C code. Don't format it. */ +/* *INDENT-OFF* */ +/* clang-format off */ + +#include "partman_devices.h" + +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +/* from */ +#define CDROM_GET_CAPABILITY 0x5331 /* get capabilities */ +#endif /* __linux__ */ + +#ifdef __FreeBSD_kernel__ +#include +#include +#endif + +#include + +#if defined(__linux__) +static int +is_cdrom(const char *path) +{ + int fd = -1; + int ret = -1; + + fd = open(path, O_RDONLY | O_NONBLOCK); + if (fd >= 0) + { + ret = ioctl(fd, CDROM_GET_CAPABILITY, NULL); + close(fd); + } + + if (ret >= 0) + return 1; + else + return 0; +} +#elif defined(__FreeBSD_kernel__) /* !__linux__ */ +static int +is_cdrom(const char *path) +{ + int fd; + + fd = open(path, O_RDONLY | O_NONBLOCK); + ioctl(fd, CDIOCCAPABILITY, NULL); + close(fd); + + if (errno != EBADF && errno != ENOTTY) + return 1; + else + return 0; +} +#else /* !__linux__ && !__FreeBSD_kernel__ */ +#define is_cdrom(path) 0 +#endif + +#if defined(__linux__) +static int +is_floppy(const char *path) +{ + return (strstr(path, "/dev/floppy") != NULL || + strstr(path, "/dev/fd") != NULL); +} +#elif defined(__FreeBSD_kernel__) /* !__linux__ */ +static int +is_floppy(const char *path) +{ + return (strstr(path, "/dev/fd") != NULL); +} +#else /* !__linux__ && !__FreeBSD_kernel__ */ +#define is_floppy(path) 0 +#endif + +static long long +process_device(PedDevice *dev) +{ + if (dev->read_only) + return -1; + if (is_cdrom(dev->path) || is_floppy(dev->path)) + return -1; + /* Exclude compcache (http://code.google.com/p/compcache/) */ + if (strstr(dev->path, "/dev/ramzswap") != NULL || + strstr(dev->path, "/dev/zram") != NULL) + return -1; + return dev->length * dev->sector_size; +} + +int +check_big_enough(long long required_space) +{ + PedDevice *dev = NULL; + ped_exception_fetch_all(); + ped_device_probe_all(); + + bool big_enough = false; + for (dev = NULL; NULL != (dev = ped_device_get_next(dev));) + { + long long dev_size = process_device(dev); + if (dev_size >= required_space) + { + big_enough = true; + break; + } + } + + // We would free the devices to release allocated memory, + // but other modules might be using partman use well, + // and they can hold pointers to libparted structures in + // other threads. + // + // So prefer to leak memory, instead. + // + // ped_device_free_all(); + + return big_enough; +} + +/* +Local variables: +indent-tabs-mode: nil +c-file-style: "linux" +c-font-lock-extra-types: ("Ped\\sw+") +End: +*/ diff --git a/calamares/src/modules/welcome/checker/partman_devices.h b/calamares/src/modules/welcome/checker/partman_devices.h new file mode 100644 index 0000000..c894f65 --- /dev/null +++ b/calamares/src/modules/welcome/checker/partman_devices.h @@ -0,0 +1,24 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014 Teo Mrnjavac + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef PARTMAN_DEVICES_H +#define PARTMAN_DEVICES_H + +#ifdef __cplusplus +extern "C" +{ +#endif + + int check_big_enough( long long required_space ); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // PARTMAN_DEVICES_H diff --git a/calamares/src/modules/welcome/language-icon-128px.png b/calamares/src/modules/welcome/language-icon-128px.png new file mode 100644 index 0000000000000000000000000000000000000000..9f4bf21477c314cdc5e459fe3beb457abda077a8 GIT binary patch literal 4634 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/welcome/language-icon-128px.png.license b/calamares/src/modules/welcome/language-icon-128px.png.license new file mode 100644 index 0000000..bc39975 --- /dev/null +++ b/calamares/src/modules/welcome/language-icon-128px.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2011 Farhat Datta and Onur Mustak Cobanli, http://www.languageicon.org/ +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/welcome/language-icon-48px.png b/calamares/src/modules/welcome/language-icon-48px.png new file mode 100644 index 0000000000000000000000000000000000000000..4012a4bee05f8f3e38a6f9efe0ff705361b094a7 GIT binary patch literal 2315 zcma)8XHXN`5>5hGKoA8(HDII$2wg*{N&->^Bs3|ZM2dvuL8N(+qF$sU0V7?J0SN*c z+NC6*NW0V#5CuXJFd&8|oq#X?et+JN-92;8w>$HFyE}7|>}<^i`6c-Q0Dz#y4U_{n zYW%TNC%E1jw4cZgr-?UQ!vFw*^M4HZiTQxTy#$lcE+o@XoHr?u5F+ash{pjmG}X2A z)HSt@iJ6^Tg!CW8Ey5jAgm)+oaLte4gOfE248=tdLhpvjn%>g=PyPHA001Nv=;%Un zv9U(_67X>EKQMSCo(KQ}Fpg$w2*Cfo1jMr^`_l6@L{H4d6nzM7&-K#zG9D&@RS3Scl&o1+V)JN(E4>uw?MZE6BE*=HY%~YImW&wHRg~ia3 znSxhuO>K(^tJ>!v7_TOAck(mgka6X%!Uv5h%4yILI&$DHZe*}BFQOaDLTZosmA%Hl zRH3!mQVu%1vUhsM5bVk~>ULv$iG~Nca97G_>%9UG*jh?UfSxX_enPcwTA{me=7L%M zM6je5UFQ58ZOVV$+XRvf8^?>hn{1uQan#W!z4Jr(LgDNdCU%TqbCWjKUool&h1` z<}~Vx=kQG^cpe<^H#9qj_@a39tO_kFo`AsdV_>yoieR1$wKvTYA7_bLbxxF=a)**m zol>~Rr|*oNxd*T@of;dNlPhNj6U$gg)g?C8-}D7RR3oiUmI4%O=yGYt43}(P)ZOmD@J8sY&m_ z+O*;6#102HH{=Rv;T0Iv9A{_}$Ug~s0YMP7ayci3r0rT-T5u$iW7(!MuQlHL#l=14 z378Z>ir7G-X$_B#mWD?}SgC@^et!i4$@uH`*)^i*<;JhU^C6A5rx`ISRI0)~POO=U zt84BTc6cCxK%r7$xyG>!mnx?~JU%BrKE4iWloW^SdzQoup;TAHlTc77baR!d&*dSr zbxRcex$3rJcZLkbJOg5(BYD)tS8x(lK9bbycfN%QZwz5(&CSg{$|A&`@F}KeS{4JJ*T92lqpiiyE77PUi1>M^oTx?+$Rj+K3{clu_EL*!U?~YgZ zHsB{qOG{CyX0Nt(cm0oeqoboyC=>>xnLkibWa0`t8HUVst#VQY3kW6hWOSRN(R_3| z-MuMP{_^2irE~4Q6(@iw`Xz&5Bz84UnorfURh-h-nIxhZxxXpj63+f`r}iBGV=Y<) zLRwT*R5;BH$j}46b4e9b_Zpw*AL8!yVH6ap-)RTNc#c!m{=F|AN;?o;kl23xCTU~97jgGF> zTR!7SOG_)EQh6U<8xO7ETrVh?xN7Jc_Di)jtdKRgx?b9sve8Wm@-hKjW{HJ{Sjm?7O* z4FGX#$sS_-puZ`MB`zc5Kai*E=;+x0u`n<XUsr~wKG1S7#tN1k)MuB_e#Q--V-P5J@ zpjA~>r~T!)RkeHsRvy?nyb@vD-H4fFHa*4 z28V}*O-)TDCQZPgPv?XZH=^lNtx>n8LK=T_W%B}EjazIVibrBkYa7hOS`UIykL;8l z4YB6A1GJ$Zv3iCPz5SzaV`D>T_R@s>xE#J6iWPV(vUdsV^LI^3=EJz&^`)hy7v<&t zU)FYGfm6K4$>ILUi=Z<+gQ>?L8NTowPn_ZF>4G1aX2RxYupXQbDIo zwlYVoz14^kdxq;)=S^P&T+onBIflsNEMhsXF1N{=Xwm%qtl3}hJE81wB(vs2Pf5_ycTi;$1eNWOiT2VcN$`F4Os z0N^;y$=>MjU=6VDUst2&wHP?g(8q{{jm-KtKQh literal 0 HcmV?d00001 diff --git a/calamares/src/modules/welcome/language-icon-48px.png.license b/calamares/src/modules/welcome/language-icon-48px.png.license new file mode 100644 index 0000000..bc39975 --- /dev/null +++ b/calamares/src/modules/welcome/language-icon-48px.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2011 Farhat Datta and Onur Mustak Cobanli, http://www.languageicon.org/ +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/welcome/tests/1a-checkinternet.conf b/calamares/src/modules/welcome/tests/1a-checkinternet.conf new file mode 100644 index 0000000..d10a97d --- /dev/null +++ b/calamares/src/modules/welcome/tests/1a-checkinternet.conf @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +requirements: + internetCheckUrl: http://example.com diff --git a/calamares/src/modules/welcome/tests/1b-checkinternet.conf b/calamares/src/modules/welcome/tests/1b-checkinternet.conf new file mode 100644 index 0000000..7cb9b42 --- /dev/null +++ b/calamares/src/modules/welcome/tests/1b-checkinternet.conf @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Nothing at all +--- +bogus: 1 diff --git a/calamares/src/modules/welcome/tests/1c-checkinternet.conf b/calamares/src/modules/welcome/tests/1c-checkinternet.conf new file mode 100644 index 0000000..6dbfb8f --- /dev/null +++ b/calamares/src/modules/welcome/tests/1c-checkinternet.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Set to blank +--- +requirements: + internetCheckUrl: "" diff --git a/calamares/src/modules/welcome/tests/1d-checkinternet.conf b/calamares/src/modules/welcome/tests/1d-checkinternet.conf new file mode 100644 index 0000000..0f5896c --- /dev/null +++ b/calamares/src/modules/welcome/tests/1d-checkinternet.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Set to something broken +--- +requirements: + internetCheckUrl: false diff --git a/calamares/src/modules/welcome/tests/1e-checkinternet.conf b/calamares/src/modules/welcome/tests/1e-checkinternet.conf new file mode 100644 index 0000000..98ff626 --- /dev/null +++ b/calamares/src/modules/welcome/tests/1e-checkinternet.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Empty list +--- +requirements: + internetCheckUrl: [] diff --git a/calamares/src/modules/welcome/tests/1f-checkinternet.conf b/calamares/src/modules/welcome/tests/1f-checkinternet.conf new file mode 100644 index 0000000..158025c --- /dev/null +++ b/calamares/src/modules/welcome/tests/1f-checkinternet.conf @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Multiple, all valid +--- +requirements: + internetCheckUrl: + - http://example.com + - http://bogus.example.com + - http://nonexistent.example.com diff --git a/calamares/src/modules/welcome/tests/1g-checkinternet.conf b/calamares/src/modules/welcome/tests/1g-checkinternet.conf new file mode 100644 index 0000000..1f4477f --- /dev/null +++ b/calamares/src/modules/welcome/tests/1g-checkinternet.conf @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Multiple, all valid, in short-list form +--- +requirements: + internetCheckUrl: [ http://example.com, http://bogus.example.com, http://nonexistent.example.com ] diff --git a/calamares/src/modules/welcome/tests/1h-checkinternet.conf b/calamares/src/modules/welcome/tests/1h-checkinternet.conf new file mode 100644 index 0000000..4984cf1 --- /dev/null +++ b/calamares/src/modules/welcome/tests/1h-checkinternet.conf @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# "0" is a valid URL (?) but "" is not +--- +requirements: + internetCheckUrl: + - http://example.com + - 0 + - "" + - http://nonexistent.example.com diff --git a/calamares/src/modules/welcome/welcome.conf b/calamares/src/modules/welcome/welcome.conf new file mode 100644 index 0000000..29cd905 --- /dev/null +++ b/calamares/src/modules/welcome/welcome.conf @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the welcome module. The welcome page +# displays some information from the branding file. +# Which parts it displays can be configured through +# the show* variables. +# +# In addition to displaying the welcome page, this module +# can check requirements for installation. +--- +# Display settings for various buttons on the welcome page. +# The URLs themselves come from `branding.desc`. Each button +# is show if the corresponding *show* setting +# here is "true". If the setting is "false", the button is hidden. +# Empty or not-set is interpreted as "false". +# +# TODO:3.3 Remove the URL fallback here; URLs only in `branding.desc` +# +# The setting can also be a full URL which will then be used +# instead of the one from the branding file. +showSupportUrl: true +showKnownIssuesUrl: true +showReleaseNotesUrl: false +# TODO:3.3 Move to branding, keep only a bool here +showDonateUrl: https://kde.org/community/donations/ + +# Requirements checking. These are general, generic, things +# that are checked. They may not match with the actual requirements +# imposed by other modules in the system. +requirements: + # Amount of available disk, in GiB. Floating-point is allowed here. + # Note that this does not account for *usable* disk, so it is possible + # to satisfy this requirement, yet have no space to install to. + requiredStorage: 5.5 + + # Amount of available RAM, in GiB. Floating-point is allowed here. + requiredRam: 1.0 + + # To check for internet connectivity, Calamares does a HTTP GET + # on this URL; on success (e.g. HTTP code 200) internet is OK. + # Use a privacy-respecting URL here, preferably in your own + # distribution domain. + # + # The URL is only used if "internet" is in the *check* list below. + internetCheckUrl: http://example.com + # + # This may be a single URL, or a list or URLs, in which case the + # URLs will be checked one-by-one; if any of them returns data, + # internet is assumed to be OK. This can be used to check via + # a number of places, where some domains may be down or blocked. + # + # To use a list of URLs, just use YAML list syntax (e.g. + # + # internetCheckUrl: + # - http://www.kde.org + # - http://www.freebsd.org + # + # or short-form + # + # internetCheckUrl: [ http://www.kde.org, http://www.freebsd.org ] + + # List conditions to check. Each listed condition will be + # probed in some way, and yields true or false according to + # the host system satisfying the condition. + # + # This sample file lists all the conditions that are known. + # + # Note that the last three checks are for testing-purposes only, + # and shouldn't be used in production (they are only available + # when building Calamares in development mode). There are five + # special checks: + # - *false* is a check that is always false (unsatisfied) + # - *true* is a check that is always true (satisfied) + # - *slow-false* takes 3 seconds, and then is false; use this one to + # show off the waiting-spinner before the first results come in + # - *slow-true* takes 3 seconds, and then is true + # - *snark* is a check that is only satisfied once it has been checked + # at least three times ("what I tell you three times is true"). + # Keep in mind that "true" and "false" are YAML keywords for + # boolean values, so should be quoted. + check: + - storage + - ram + - power + - internet + - root + - screen + - "false" + - slow-true + - snark + # List conditions that **must** be satisfied (from the list + # of conditions, above) for installation to proceed. + # If any of these conditions are not met, the user cannot + # continue past the welcome page. + required: + # - storage + - ram + # - root + +# GeoIP checking +# +# This can be used to pre-select a language based on the country +# the user is currently in. It *assumes* that there's internet +# connectivity, though. Configuration is like in the locale module, +# but remember to use a URL that returns full data **and** to +# use a selector that will pick the country, not the timezone. +# +# To disable GeoIP checking, either comment-out the entire geoip section, +# or set the *style* key to an unsupported format (e.g. `none`). +# Also, note the analogous feature in `src/modules/locale/locale.conf`, +# which is where you will find complete documentation. +# +# For testing, the *style* may be set to `fixed`, any URL that +# returns data (e.g. `http://example.com`) and then *selector* +# sets the data that is actually returned (e.g. "DE" to simulate +# the machine being in Germany). +# +# NOTE: the *selector* must pick the country code from the GeoIP +# data. Timezone, city, or other data will not be recognized. +# +geoip: + style: "none" + url: "https://geoip.kde.org/v1/ubiquity" # extended XML format + selector: "CountryCode" # blank uses default, which is wrong + +# User interface +# +# The "select language" icon is an international standard, but it +# might not theme very well with your desktop environment. +# Fill in an icon name (following FreeDesktop standards) to +# use that named icon instead of the usual one. +# +# Leave blank or unset to use the international standard. +# +# Known icons in this space are "set-language" and "config-language". +# +# languageIcon: set-language diff --git a/calamares/src/modules/welcome/welcome.qrc b/calamares/src/modules/welcome/welcome.qrc new file mode 100644 index 0000000..f270ee5 --- /dev/null +++ b/calamares/src/modules/welcome/welcome.qrc @@ -0,0 +1,6 @@ + + + language-icon-128px.png + language-icon-48px.png + + diff --git a/calamares/src/modules/welcome/welcome.schema.yaml b/calamares/src/modules/welcome/welcome.schema.yaml new file mode 100644 index 0000000..30b3e48 --- /dev/null +++ b/calamares/src/modules/welcome/welcome.schema.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/welcome +additionalProperties: false +type: object +properties: + # TODO:3.3: drop the string alternatives and put the URL part in Branding + showSupportUrl: { anyOf: [ { type: boolean, default: true }, { type: string } ] } + showKnownIssuesUrl: { anyOf: [ { type: boolean, default: true }, { type: string } ] } + showReleaseNotesUrl: { anyOf: [ { type: boolean, default: true }, { type: string } ] } + showDonateUrl: { anyOf: [ { type: boolean, default: true }, { type: string } ] } + + requirements: + additionalProperties: false + type: object + properties: + requiredStorage: { type: number } + requiredRam: { type: number } + internetCheckUrl: { type: string } + check: + type: array + items: { type: string, enum: [storage, ram, power, internet, root, screen, "false", "true", "slow-false", "slow-true", snark], unique: true } + required: # Key-name in the config-file + type: array + items: { type: string, enum: [storage, ram, power, internet, root, screen, "false", "true", "slow-false", "slow-true", snark], unique: true } + required: [ requiredStorage, requiredRam, check ] # Schema keyword + + # TODO: refactor, this is reused in locale + geoip: + additionalProperties: false + type: object + properties: + style: { type: string, enum: [ none, fixed, xml, json ] } + url: { type: string } + selector: { type: string } + required: [ style, url, selector ] diff --git a/calamares/src/modules/welcomeq/CMakeLists.txt b/calamares/src/modules/welcomeq/CMakeLists.txt new file mode 100644 index 0000000..98b5a16 --- /dev/null +++ b/calamares/src/modules/welcomeq/CMakeLists.txt @@ -0,0 +1,48 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# This is a re-write of the welcome module using QML view steps +# instead of widgets. + +if(NOT WITH_QML) + calamares_skip_module( "welcomeq (QML is not supported in this build)" ) + return() +endif() + +set(_welcome ${CMAKE_CURRENT_SOURCE_DIR}/../welcome) + +include_directories(${_welcome}) + +# DUPLICATED WITH WELCOME MODULE +find_package(${qtname} ${QT_VERSION} CONFIG REQUIRED DBus Network) + +find_package(LIBPARTED) +if(LIBPARTED_FOUND) + set(PARTMAN_SRC ${_welcome}/checker/partman_devices.c) + set(CHECKER_LINK_LIBRARIES ${LIBPARTED_LIBRARY}) +else() + set(PARTMAN_SRC) + set(CHECKER_LINK_LIBRARIES) + add_definitions(-DWITHOUT_LIBPARTED) +endif() + +set(CHECKER_SOURCES ${_welcome}/checker/GeneralRequirements.cpp ${PARTMAN_SRC}) + +calamares_add_plugin(welcomeq + TYPE viewmodule + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + ${CHECKER_SOURCES} + WelcomeQmlViewStep.cpp + ${_welcome}/Config.cpp + RESOURCES + welcomeq${QT_VERSION_SUFFIX}.qrc + LINK_PRIVATE_LIBRARIES + ${CHECKER_LINK_LIBRARIES} + ${qtname}::DBus + ${qtname}::Network + SHARED_LIB +) diff --git a/calamares/src/modules/welcomeq/Recommended.qml b/calamares/src/modules/welcomeq/Recommended.qml new file mode 100644 index 0000000..1afc609 --- /dev/null +++ b/calamares/src/modules/welcomeq/Recommended.qml @@ -0,0 +1,91 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Anke Boersma + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* THIS COMPONENT IS UNUSED -- from the default welcomeq.qml at least */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.7 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.7 as Kirigami + +Rectangle { + focus: true + Kirigami.Theme.backgroundColor: Kirigami.Theme.backgroundColor + anchors.fill: parent + anchors.topMargin: 50 + + TextArea { + id: recommended + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 1 + horizontalAlignment: TextEdit.AlignHCenter + width: 640 + font.pointSize: 11 + textFormat: Text.RichText + antialiasing: true + activeFocusOnPress: false + wrapMode: Text.WordWrap + + text: qsTr("

    This computer does not satisfy some of the recommended requirements for setting up %1.
    + Setup can continue, but some features might be disabled.

    ").arg(Branding.string(Branding.VersionedName)) + } + + Rectangle { + width: 640 + height: 360 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: recommended.bottom + anchors.topMargin: 5 + + Component { + id: requirementsDelegate + + Item { + width: 640 + height: 35 + + Column { + anchors.centerIn: parent + + Rectangle { + implicitWidth: 640 + implicitHeight: 35 + border.color: satisfied ? "#228b22" : "#ffa411" + color: satisfied ? "#f0fff0" : "#ffefd5" + + Image { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.margins: 20 + source: satisfied ? "qrc:/data/images/yes.svgz" : "qrc:/data/images/information.svgz" + } + + Text { + text: satisfied ? details : negatedText + anchors.centerIn: parent + font.pointSize: 11 + } + } + } + } + } + + ListView { + anchors.fill: parent + spacing: 5 + model: config.requirementsModel + delegate: requirementsDelegate + } + } +} diff --git a/calamares/src/modules/welcomeq/Requirements.qml b/calamares/src/modules/welcomeq/Requirements.qml new file mode 100644 index 0000000..949ac32 --- /dev/null +++ b/calamares/src/modules/welcomeq/Requirements.qml @@ -0,0 +1,108 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2022 Anke Boersma + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.7 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.7 as Kirigami + +Rectangle { + focus: true + Kirigami.Theme.backgroundColor: Kirigami.Theme.backgroundColor + anchors.fill: parent + anchors.topMargin: 60 + + TextArea { + id: required + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + horizontalAlignment: TextEdit.AlignHCenter + width: parent.width + font.pointSize: 11 + textFormat: Text.RichText + antialiasing: true + activeFocusOnPress: false + wrapMode: Text.WordWrap + + property var requirementsText: qsTr("

    This computer does not satisfy the minimum requirements for installing %1.
    + Installation cannot continue.

    ").arg(Branding.string(Branding.VersionedName)) + property var recommendationsText: qsTr("

    This computer does not satisfy some of the recommended requirements for setting up %1.
    + Setup can continue, but some features might be disabled.

    ").arg(Branding.string(Branding.VersionedName)) + + text: config.requirementsModel.satisfiedMandatory ? recommendationsText : requirementsText + } + + Rectangle { + width: parent.width * 0.8 + height: parent.height * 0.6 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: required.bottom + anchors.topMargin: 5 + + Component { + id: requirementsDelegate + + Item { + width: parent.width + implicitHeight: message.implicitHeight + 30 + visible: true + + Column { + anchors.centerIn: parent + + Rectangle { + implicitWidth: 640 + implicitHeight: message.implicitHeight + 30 + // Colors and images based on the two satisfied-bools: + // - if satisfied, then green / ok + // - otherwise if mandatory, then red / stop + // - otherwise, then yellow / warning + border.color: satisfied ? "#228b22" : (mandatory ? "#ff0000" : "#ffa411") + color: satisfied ? "#f0fff0" : (mandatory ? "#ffc0cb" : "#ffefd5") + + Image { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.margins: 20 + source: satisfied ? "qrc:/data/images/yes.svgz" : (mandatory ? "qrc:/data/images/no.svgz" : "qrc:/data/images/information.svgz") + } + + Text { + id: message + text: satisfied ? details : negatedText + anchors.centerIn: parent + font.pointSize: 11 + } + } + } + } + } + + ListView { + id: requirementsList + anchors.fill: parent + spacing: 5 + clip: true + // This uses the unfiltered model, so that all requirements are + // shown. You could use *unsatisfiedRequirements* to get the + // filtered model so that only unsatisfied requirements are ever shown. + //model: config.unsatisfiedRequirements + model: config.requirementsModel + delegate: requirementsDelegate + ScrollBar.vertical: ScrollBar { + active: true + } + } + } +} + diff --git a/calamares/src/modules/welcomeq/WelcomeQmlViewStep.cpp b/calamares/src/modules/welcomeq/WelcomeQmlViewStep.cpp new file mode 100644 index 0000000..7bd866b --- /dev/null +++ b/calamares/src/modules/welcomeq/WelcomeQmlViewStep.cpp @@ -0,0 +1,94 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2014-2015 Teo Mrnjavac + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "WelcomeQmlViewStep.h" + +#include "checker/GeneralRequirements.h" + +#include "locale/TranslationsModel.h" +#include "utils/Dirs.h" +#include "utils/Logger.h" +#include "utils/Variant.h" + +#include "Branding.h" +#include "modulesystem/ModuleManager.h" +#include "utils/Yaml.h" + +CALAMARES_PLUGIN_FACTORY_DEFINITION( WelcomeQmlViewStepFactory, registerPlugin< WelcomeQmlViewStep >(); ) + +WelcomeQmlViewStep::WelcomeQmlViewStep( QObject* parent ) + : Calamares::QmlViewStep( parent ) + , m_config( new Config( this ) ) +{ + connect( Calamares::ModuleManager::instance(), + &Calamares::ModuleManager::requirementsComplete, + this, + &WelcomeQmlViewStep::nextStatusChanged ); +} + + +QString +WelcomeQmlViewStep::prettyName() const +{ + return tr( "Welcome", "@title" ); +} + +bool +WelcomeQmlViewStep::isNextEnabled() const +{ + return m_config->requirementsModel()->satisfiedMandatory(); +} + +bool +WelcomeQmlViewStep::isBackEnabled() const +{ + // TODO: should return true (it's weird that you are not allowed to have welcome *after* anything + return false; +} + + +bool +WelcomeQmlViewStep::isAtBeginning() const +{ + return true; +} + + +bool +WelcomeQmlViewStep::isAtEnd() const +{ + return true; +} + + +Calamares::JobList +WelcomeQmlViewStep::jobs() const +{ + return Calamares::JobList(); +} + +void +WelcomeQmlViewStep::setConfigurationMap( const QVariantMap& configurationMap ) +{ + m_config->setConfigurationMap( configurationMap ); + Calamares::QmlViewStep::setConfigurationMap( configurationMap ); // call parent implementation last +} + +Calamares::RequirementsList +WelcomeQmlViewStep::checkRequirements() +{ + return m_config->checkRequirements(); +} + +QObject* +WelcomeQmlViewStep::getConfig() +{ + return m_config; +} diff --git a/calamares/src/modules/welcomeq/WelcomeQmlViewStep.h b/calamares/src/modules/welcomeq/WelcomeQmlViewStep.h new file mode 100644 index 0000000..1839c7c --- /dev/null +++ b/calamares/src/modules/welcomeq/WelcomeQmlViewStep.h @@ -0,0 +1,70 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef WELCOME_QMLVIEWSTEP_H +#define WELCOME_QMLVIEWSTEP_H + +#include "Config.h" + +#include "modulesystem/Requirement.h" +#include "utils/PluginFactory.h" +#include "viewpages/QmlViewStep.h" + +#include + +#include +#include + +namespace Calamares +{ +namespace GeoIP +{ +class Handler; +} // namespace GeoIP +} // namespace Calamares + + +// TODO: refactor and move what makes sense to base class +class PLUGINDLLEXPORT WelcomeQmlViewStep : public Calamares::QmlViewStep +{ + Q_OBJECT + +public: + explicit WelcomeQmlViewStep( QObject* parent = nullptr ); + + QString prettyName() const override; + + bool isNextEnabled() const override; + bool isBackEnabled() const override; + + bool isAtBeginning() const override; + bool isAtEnd() const override; + + Calamares::JobList jobs() const override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + + /** @brief Sets the country that Calamares is running in. + * + * This (ideally) sets up language and locale settings that are right for + * the given 2-letter country code. Uses the handler's information (if + * given) for error reporting. + */ + void setCountry( const QString&, Calamares::GeoIP::Handler* handler ); + + Calamares::RequirementsList checkRequirements() override; + QObject* getConfig() override; + +private: + Config* m_config; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( WelcomeQmlViewStepFactory ) + +#endif // WELCOME_QMLVIEWSTEP_H diff --git a/calamares/src/modules/welcomeq/img/chevron-left-solid.svg b/calamares/src/modules/welcomeq/img/chevron-left-solid.svg new file mode 100644 index 0000000..41061c2 --- /dev/null +++ b/calamares/src/modules/welcomeq/img/chevron-left-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/calamares/src/modules/welcomeq/img/chevron-left-solid.svg.license b/calamares/src/modules/welcomeq/img/chevron-left-solid.svg.license new file mode 100644 index 0000000..5f43e65 --- /dev/null +++ b/calamares/src/modules/welcomeq/img/chevron-left-solid.svg.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2020 demmm +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/welcomeq/img/language-icon-48px.png b/calamares/src/modules/welcomeq/img/language-icon-48px.png new file mode 100644 index 0000000000000000000000000000000000000000..4012a4bee05f8f3e38a6f9efe0ff705361b094a7 GIT binary patch literal 2315 zcma)8XHXN`5>5hGKoA8(HDII$2wg*{N&->^Bs3|ZM2dvuL8N(+qF$sU0V7?J0SN*c z+NC6*NW0V#5CuXJFd&8|oq#X?et+JN-92;8w>$HFyE}7|>}<^i`6c-Q0Dz#y4U_{n zYW%TNC%E1jw4cZgr-?UQ!vFw*^M4HZiTQxTy#$lcE+o@XoHr?u5F+ash{pjmG}X2A z)HSt@iJ6^Tg!CW8Ey5jAgm)+oaLte4gOfE248=tdLhpvjn%>g=PyPHA001Nv=;%Un zv9U(_67X>EKQMSCo(KQ}Fpg$w2*Cfo1jMr^`_l6@L{H4d6nzM7&-K#zG9D&@RS3Scl&o1+V)JN(E4>uw?MZE6BE*=HY%~YImW&wHRg~ia3 znSxhuO>K(^tJ>!v7_TOAck(mgka6X%!Uv5h%4yILI&$DHZe*}BFQOaDLTZosmA%Hl zRH3!mQVu%1vUhsM5bVk~>ULv$iG~Nca97G_>%9UG*jh?UfSxX_enPcwTA{me=7L%M zM6je5UFQ58ZOVV$+XRvf8^?>hn{1uQan#W!z4Jr(LgDNdCU%TqbCWjKUool&h1` z<}~Vx=kQG^cpe<^H#9qj_@a39tO_kFo`AsdV_>yoieR1$wKvTYA7_bLbxxF=a)**m zol>~Rr|*oNxd*T@of;dNlPhNj6U$gg)g?C8-}D7RR3oiUmI4%O=yGYt43}(P)ZOmD@J8sY&m_ z+O*;6#102HH{=Rv;T0Iv9A{_}$Ug~s0YMP7ayci3r0rT-T5u$iW7(!MuQlHL#l=14 z378Z>ir7G-X$_B#mWD?}SgC@^et!i4$@uH`*)^i*<;JhU^C6A5rx`ISRI0)~POO=U zt84BTc6cCxK%r7$xyG>!mnx?~JU%BrKE4iWloW^SdzQoup;TAHlTc77baR!d&*dSr zbxRcex$3rJcZLkbJOg5(BYD)tS8x(lK9bbycfN%QZwz5(&CSg{$|A&`@F}KeS{4JJ*T92lqpiiyE77PUi1>M^oTx?+$Rj+K3{clu_EL*!U?~YgZ zHsB{qOG{CyX0Nt(cm0oeqoboyC=>>xnLkibWa0`t8HUVst#VQY3kW6hWOSRN(R_3| z-MuMP{_^2irE~4Q6(@iw`Xz&5Bz84UnorfURh-h-nIxhZxxXpj63+f`r}iBGV=Y<) zLRwT*R5;BH$j}46b4e9b_Zpw*AL8!yVH6ap-)RTNc#c!m{=F|AN;?o;kl23xCTU~97jgGF> zTR!7SOG_)EQh6U<8xO7ETrVh?xN7Jc_Di)jtdKRgx?b9sve8Wm@-hKjW{HJ{Sjm?7O* z4FGX#$sS_-puZ`MB`zc5Kai*E=;+x0u`n<XUsr~wKG1S7#tN1k)MuB_e#Q--V-P5J@ zpjA~>r~T!)RkeHsRvy?nyb@vD-H4fFHa*4 z28V}*O-)TDCQZPgPv?XZH=^lNtx>n8LK=T_W%B}EjazIVibrBkYa7hOS`UIykL;8l z4YB6A1GJ$Zv3iCPz5SzaV`D>T_R@s>xE#J6iWPV(vUdsV^LI^3=EJz&^`)hy7v<&t zU)FYGfm6K4$>ILUi=Z<+gQ>?L8NTowPn_ZF>4G1aX2RxYupXQbDIo zwlYVoz14^kdxq;)=S^P&T+onBIflsNEMhsXF1N{=Xwm%qtl3}hJE81wB(vs2Pf5_ycTi;$1eNWOiT2VcN$`F4Os z0N^;y$=>MjU=6VDUst2&wHP?g(8q{{jm-KtKQh literal 0 HcmV?d00001 diff --git a/calamares/src/modules/welcomeq/img/language-icon-48px.png.license b/calamares/src/modules/welcomeq/img/language-icon-48px.png.license new file mode 100644 index 0000000..bc39975 --- /dev/null +++ b/calamares/src/modules/welcomeq/img/language-icon-48px.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2011 Farhat Datta and Onur Mustak Cobanli, http://www.languageicon.org/ +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/welcomeq/img/squid.png b/calamares/src/modules/welcomeq/img/squid.png new file mode 100644 index 0000000000000000000000000000000000000000..452e4450c56c10cda33dcc9c5d03753ace458862 GIT binary patch literal 8313 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 +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/calamares/src/modules/welcomeq/release_notes.qml b/calamares/src/modules/welcomeq/release_notes.qml new file mode 100644 index 0000000..29ba7c0 --- /dev/null +++ b/calamares/src/modules/welcomeq/release_notes.qml @@ -0,0 +1,94 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 - 2024 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +import io.calamares.ui 1.0 + +import QtQuick 2.7 +import QtQuick.Controls 2.2 +import QtQuick.Window 2.2 +import QtQuick.Layouts 1.3 + +Rectangle { + width: parent.width + height: parent.height + focus: true + color: "#f2f2f2" + + Flickable { + id: flick + anchors.fill: parent + contentHeight: 3500 + + ScrollBar.vertical: ScrollBar { + id: fscrollbar + width: 10 + policy: ScrollBar.AlwaysOn + } + + TextArea { + id: intro + x: 130 + y: 8 + width: 640 + font.pointSize: 14 + textFormat: Text.MarkdownText + antialiasing: true + activeFocusOnPress: false + wrapMode: Text.WordWrap + + text: qsTr("### %1 +This an example QML file, showing options in Markdown with Flickable content. + +QML with RichText can use HTML tags, with Markdown it uses the simple Markdown syntax, Flickable content is useful for touchscreens. + +**This is bold text** + +*This is italic text* + +_This is underlined text_ + +> blockquote + +~~This is strikethrough~~ + +Code example: +``` +ls -l /home +``` + +**Lists:** + * Intel CPU systems + * AMD CPU systems + +The vertical scrollbar is adjustable, current width set to 10.").arg(Branding.string(Branding.VersionedName)) + + } + } + + ToolButton { + id: toolButton + x: 19 + y: 29 + width: 105 + height: 48 + text: qsTr("Back") + hoverEnabled: true + onClicked: load.source = "" + + Image { + id: image1 + x: 0 + y: 13 + width: 22 + height: 22 + source: "img/chevron-left-solid.svg" + fillMode: Image.PreserveAspectFit + } + } +} diff --git a/calamares/src/modules/welcomeq/welcomeq-qt6.qml b/calamares/src/modules/welcomeq/welcomeq-qt6.qml new file mode 100644 index 0000000..7cc8c64 --- /dev/null +++ b/calamares/src/modules/welcomeq/welcomeq-qt6.qml @@ -0,0 +1,169 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.10 +import QtQuick.Controls 2.10 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.3 + +// Qt6 requires unversioned imports and other names +import org.kde.kirigami as Kirigami +import Qt5Compat.GraphicalEffects + + +Page +{ + id: welcome + + header: Item { + width: parent.width + height: parent.height + + Text { + id: welcomeTopText + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + horizontalAlignment: Text.AlignHCenter + padding: 20 + // In QML, QString::arg() only takes one argument + text: qsTr("

    Welcome to the %1 %2 installer

    +

    This program will ask you some questions and set up %1 on your computer.

    ").arg(Branding.string(Branding.ProductName)).arg(Branding.string(Branding.Version)) + } + Image { + id: welcomeImage + anchors.centerIn: parent + // imagePath() returns a full pathname, so make it refer to the filesystem + // .. otherwise the path is interpreted relative to the "call site", which + // .. might be the QRC file. + source: "file:/" + Branding.imagePath(Branding.ProductWelcome) + sourceSize.width: width + sourceSize.height: height + fillMode: Image.PreserveAspectFit + } + + Requirements { + visible: !config.requirementsModel.satisfiedRequirements + } + + RowLayout { + id: buttonBar + width: parent.width / 1.5 + height: 64 + + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + + spacing: Kirigami.Units.largeSpacing* 2 + + Button { + Layout.fillWidth: true + text: qsTr("Support") + icon.name: "system-help" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.supportUrl !== "" + onClicked: Qt.openUrlExternally(config.supportUrl) + } + + Button { + Layout.fillWidth: true + text: qsTr("Known Issues") + icon.name: "tools-report-bug" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.knownIssuesUrl !== "" + onClicked: Qt.openUrlExternally(config.knownIssuesUrl) + } + + Button { + Layout.fillWidth: true + text: qsTr("Release Notes") + icon.name: "folder-text" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.releaseNotesUrl !== "" + onClicked: load.source = "release_notes.qml" + //onClicked: load.source = "file:/usr/share/calamares/release_notes.qml" + } + + Button { + Layout.fillWidth: true + text: qsTr("Donate") + icon.name: "taxes-finances" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.donateUrl !== "" + onClicked: Qt.openUrlExternally(config.donateUrl) + } + } + + RowLayout { + id: languageBar + width: parent.width /1.2 + height: 48 + + anchors.bottom: parent.bottom + anchors.bottomMargin: parent.height /7 + anchors.horizontalCenter: parent.horizontalCenter + spacing: Kirigami.Units.largeSpacing* 4 + + Rectangle { + width: parent.width + Layout.fillWidth: true + focus: true + + Loader { + id: imLoader + + Component { + id: icon + Kirigami.Icon { + source: config.languageIcon + height: 48 + width: 48 + } + } + + Component { + id: image + Image { + height: 48 + fillMode: Image.PreserveAspectFit + source: "img/language-icon-48px.png" + } + } + + sourceComponent: (config.languageIcon != "") ? icon : image + } + + ComboBox { + id: languages + anchors.left: imLoader.right + width: languageBar.width /1.1 + textRole: "label" + currentIndex: config.localeIndex + model: config.languagesModel + onCurrentIndexChanged: config.localeIndex = currentIndex + } + } + } + + Loader { + id:load + anchors.fill: parent + } + } +} diff --git a/calamares/src/modules/welcomeq/welcomeq-qt6.qrc b/calamares/src/modules/welcomeq/welcomeq-qt6.qrc new file mode 100644 index 0000000..a6a211f --- /dev/null +++ b/calamares/src/modules/welcomeq/welcomeq-qt6.qrc @@ -0,0 +1,11 @@ + + + welcomeq-qt6.qml + release_notes.qml + Recommended.qml + Requirements.qml + img/squid.png + img/chevron-left-solid.svg + img/language-icon-48px.png + + diff --git a/calamares/src/modules/welcomeq/welcomeq.conf b/calamares/src/modules/welcomeq/welcomeq.conf new file mode 100644 index 0000000..2efc514 --- /dev/null +++ b/calamares/src/modules/welcomeq/welcomeq.conf @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# Configuration for the welcomeq module. +# +# The configuration for welcomeq is exactly the same +# as the welcome module, with the one exception of +# *qmlSearch* which governs QML loading. +# +# No documentation is given here: look in the welcome module. +--- +# Setting for QML loading: use QRC, branding, or both sources of files +qmlSearch: both + +# Everythin below here is documented in `welcome.conf` +showSupportUrl: true +showKnownIssuesUrl: true +showReleaseNotesUrl: true +# showDonateUrl: https://kde.org/community/donations/ + +requirements: + requiredStorage: 5.5 + requiredRam: 1.0 + internetCheckUrl: http://google.com + check: + - storage + - ram + - power + - internet + - root + - screen + required: + - ram + +geoip: + style: "none" + url: "https://geoip.kde.org/v1/ubiquity" # extended XML format + selector: "CountryCode" # blank uses default, which is wrong + +#languageIcon: languages diff --git a/calamares/src/modules/welcomeq/welcomeq.qml b/calamares/src/modules/welcomeq/welcomeq.qml new file mode 100644 index 0000000..d3150cf --- /dev/null +++ b/calamares/src/modules/welcomeq/welcomeq.qml @@ -0,0 +1,169 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2020 Adriaan de Groot + * SPDX-FileCopyrightText: 2020 Anke Boersma + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ +import io.calamares.core 1.0 +import io.calamares.ui 1.0 + +import QtQuick 2.10 +import QtQuick.Controls 2.10 +import QtQuick.Layouts 1.3 +import QtQuick.Window 2.3 + +// Qt5 requires versioned imports +// +import org.kde.kirigami 2.7 as Kirigami +import QtGraphicalEffects 1.0 + +Page +{ + id: welcome + + header: Item { + width: parent.width + height: parent.height + + Text { + id: welcomeTopText + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + horizontalAlignment: Text.AlignHCenter + padding: 20 + // In QML, QString::arg() only takes one argument + text: qsTr("

    Welcome to the %1 %2 installer

    +

    This program will ask you some questions and set up %1 on your computer.

    ").arg(Branding.string(Branding.ProductName)).arg(Branding.string(Branding.Version)) + } + Image { + id: welcomeImage + anchors.centerIn: parent + // imagePath() returns a full pathname, so make it refer to the filesystem + // .. otherwise the path is interpreted relative to the "call site", which + // .. might be the QRC file. + source: "file:/" + Branding.imagePath(Branding.ProductWelcome) + sourceSize.width: width + sourceSize.height: height + fillMode: Image.PreserveAspectFit + } + + Requirements { + visible: !config.requirementsModel.satisfiedRequirements + } + + RowLayout { + id: buttonBar + width: parent.width / 1.5 + height: 64 + + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + + spacing: Kirigami.Units.largeSpacing* 2 + + Button { + Layout.fillWidth: true + text: qsTr("Support") + icon.name: "system-help" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.supportUrl !== "" + onClicked: Qt.openUrlExternally(config.supportUrl) + } + + Button { + Layout.fillWidth: true + text: qsTr("Known Issues") + icon.name: "tools-report-bug" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.knownIssuesUrl !== "" + onClicked: Qt.openUrlExternally(config.knownIssuesUrl) + } + + Button { + Layout.fillWidth: true + text: qsTr("Release Notes") + icon.name: "folder-text" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.releaseNotesUrl !== "" + onClicked: load.source = "release_notes.qml" + //onClicked: load.source = "file:/usr/share/calamares/release_notes.qml" + } + + Button { + Layout.fillWidth: true + text: qsTr("Donate") + icon.name: "taxes-finances" + Kirigami.Theme.backgroundColor: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.4) + Kirigami.Theme.textColor: Kirigami.Theme.textColor + + visible: config.donateUrl !== "" + onClicked: Qt.openUrlExternally(config.donateUrl) + } + } + + RowLayout { + id: languageBar + width: parent.width /1.2 + height: 48 + + anchors.bottom: parent.bottom + anchors.bottomMargin: parent.height /7 + anchors.horizontalCenter: parent.horizontalCenter + spacing: Kirigami.Units.largeSpacing* 4 + + Rectangle { + width: parent.width + Layout.fillWidth: true + focus: true + + Loader { + id: imLoader + + Component { + id: icon + Kirigami.Icon { + source: config.languageIcon + height: 48 + width: 48 + } + } + + Component { + id: image + Image { + height: 48 + fillMode: Image.PreserveAspectFit + source: "img/language-icon-48px.png" + } + } + + sourceComponent: (config.languageIcon != "") ? icon : image + } + + ComboBox { + id: languages + anchors.left: imLoader.right + width: languageBar.width /1.1 + textRole: "label" + currentIndex: config.localeIndex + model: config.languagesModel + onCurrentIndexChanged: config.localeIndex = currentIndex + } + } + } + + Loader { + id:load + anchors.fill: parent + } + } +} diff --git a/calamares/src/modules/welcomeq/welcomeq.qrc b/calamares/src/modules/welcomeq/welcomeq.qrc new file mode 100644 index 0000000..6f7a9c5 --- /dev/null +++ b/calamares/src/modules/welcomeq/welcomeq.qrc @@ -0,0 +1,11 @@ + + + welcomeq.qml + release_notes.qml + Recommended.qml + Requirements.qml + img/squid.png + img/chevron-left-solid.svg + img/language-icon-48px.png + + diff --git a/calamares/src/modules/zfs/CMakeLists.txt b/calamares/src/modules/zfs/CMakeLists.txt new file mode 100644 index 0000000..07764a3 --- /dev/null +++ b/calamares/src/modules/zfs/CMakeLists.txt @@ -0,0 +1,12 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# +calamares_add_plugin(zfs + TYPE job + EXPORT_MACRO PLUGINDLLEXPORT_PRO + SOURCES + ZfsJob.cpp + SHARED_LIB +) diff --git a/calamares/src/modules/zfs/README.md b/calamares/src/modules/zfs/README.md new file mode 100644 index 0000000..992fa5c --- /dev/null +++ b/calamares/src/modules/zfs/README.md @@ -0,0 +1,21 @@ +## zfs Module Notes + + + +There are a few considerations to be aware of when enabling the zfs module +* You must provide zfs kernel modules or kernel support on the ISO for the zfs module to function + * The zfs kernel module must be loaded prior to the partition module running + * One way to achieve this is by running `modprobe zfs` +* Support for zfs in the partition module is conditional on the zfs module being enabled +* The config for the default pools and datasets is configured and described in modules/zfs.conf +* If you use grub with zfs, you must have `ZPOOL_VDEV_NAME_PATH=1` in your environment when running grub-install or grub-mkconfig. + * Calamares will ensure this happens during the bootloader module. + * It will also add it to `/etc/environment` so it will be available in the installation + * If you have an scripts or other processes that trigger grub-mkconfig during the install process, be sure to add that to the environment +* In most cases, you will need to enable services for zfs support appropriate to your distro. For example, when testing on Arch the following services were enabled: + * zfs.target + * zfs-import-cache + * zfs-mount + * zfs-import.target diff --git a/calamares/src/modules/zfs/ZfsJob.cpp b/calamares/src/modules/zfs/ZfsJob.cpp new file mode 100644 index 0000000..22487a8 --- /dev/null +++ b/calamares/src/modules/zfs/ZfsJob.cpp @@ -0,0 +1,371 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Evan James + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#include "ZfsJob.h" + +#include "utils/Logger.h" +#include "utils/System.h" +#include "utils/Variant.h" + +#include "GlobalStorage.h" +#include "JobQueue.h" +#include "Settings.h" + +#include +#include + +#include + +/** @brief Returns the alphanumeric portion of a string + * + * @p input is the input string + * + */ +static QString +alphaNumeric( QString input ) +{ + return input.remove( QRegularExpression( "[^a-zA-Z\\d\\s]" ) ); +} + +/** @brief Returns the best available device for zpool creation + * + * zfs partitions generally don't have UUID until the zpool is created. Generally, + * they are formed using either the id or the partuuid. The id isn't stored by kpmcore + * so this function checks to see if we have a partuuid. If so, it forms a device path + * for it. As a backup, it uses the device name i.e. /dev/sdax. + * + * The function returns a fully qualified path to the device or an empty string if no device + * is found + * + * @p pMap is the partition map from global storage + * + */ +static QString +findBestZfsDevice( QVariantMap pMap ) +{ + // Find the best device identifier, if one isn't available, skip this partition + QString deviceName; + if ( pMap[ "partuuid" ].toString() != "" ) + { + return "/dev/disk/by-partuuid/" + pMap[ "partuuid" ].toString().toLower(); + } + else if ( pMap[ "device" ].toString() != "" ) + { + return pMap[ "device" ].toString().toLower(); + } + else + { + return QString(); + } +} + +/** @brief Converts the value in a QVariant to a string which is a valid option for canmount + * + * Storing "on" and "off" in QVariant results in a conversion to boolean. This function takes + * the Qvariant in @p canMount and converts it to a QString holding "on", "off" or the string + * value in the QVariant. + * + */ +static QString +convertCanMount( QVariant canMount ) +{ + if ( canMount == true ) + { + return "on"; + } + else if ( canMount == false ) + { + return "off"; + } + else + { + return canMount.toString(); + } +} + +ZfsJob::ZfsJob( QObject* parent ) + : Calamares::CppJob( parent ) +{ +} + +ZfsJob::~ZfsJob() {} + +QString +ZfsJob::prettyName() const +{ + return tr( "Creating ZFS pools and datasets…", "@status" ); +} + +void +ZfsJob::collectMountpoints( const QVariantList& partitions ) +{ + m_mountpoints.empty(); + for ( const QVariant& partition : partitions ) + { + if ( partition.canConvert< QVariantMap >() ) + { + QString mountpoint = partition.toMap().value( "mountPoint" ).toString(); + if ( !mountpoint.isEmpty() ) + { + m_mountpoints.append( mountpoint ); + } + } + } +} + +bool +ZfsJob::isMountpointOverlapping( const QString& targetMountpoint ) const +{ + for ( const QString& mountpoint : m_mountpoints ) + { + if ( mountpoint != '/' && targetMountpoint.startsWith( mountpoint ) ) + { + return true; + } + } + return false; +} + +ZfsResult +ZfsJob::createZpool( QString deviceName, QString poolName, QString poolOptions, bool encrypt, QString passphrase ) const +{ + // zfs doesn't wait for the devices so pause for 2 seconds to ensure we give time for the device files to be created + sleep( 2 ); + + QStringList command; + if ( encrypt ) + { + command = QStringList() << "zpool" + << "create" << poolOptions.split( ' ' ) << "-O" + << "encryption=aes-256-gcm" + << "-O" + << "keyformat=passphrase" << poolName << deviceName; + } + else + { + command = QStringList() << "zpool" + << "create" << poolOptions.split( ' ' ) << poolName << deviceName; + } + + auto r = Calamares::System::instance()->runCommand( + Calamares::System::RunLocation::RunInHost, command, QString(), passphrase, std::chrono::seconds( 10 ) ); + + if ( r.getExitCode() != 0 ) + { + cWarning() << "Failed to run zpool create. The output was: " + r.getOutput(); + return { false, tr( "Failed to create zpool on " ) + deviceName }; + } + + return { true, QString() }; +} + +Calamares::JobResult +ZfsJob::exec() +{ + QVariantList partitions; + Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage(); + if ( gs && gs->contains( "partitions" ) && gs->value( "partitions" ).canConvert< QVariantList >() ) + { + partitions = gs->value( "partitions" ).toList(); + } + else + { + cWarning() << "No *partitions* defined."; + return Calamares::JobResult::internalError( tr( "Configuration Error" ), + tr( "No partitions are available for ZFS." ), + Calamares::JobResult::InvalidConfiguration ); + } + + const Calamares::System* system = Calamares::System::instance(); + + QVariantList poolNames; + + // Check to ensure the list of zfs info from the partition module is available and convert it to a list + if ( !gs->contains( "zfsInfo" ) && gs->value( "zfsInfo" ).canConvert< QVariantList >() ) + { + return Calamares::JobResult::error( tr( "Internal data missing" ), tr( "Failed to create zpool" ) ); + } + QVariantList zfsInfoList = gs->value( "zfsInfo" ).toList(); + + for ( auto& partition : std::as_const( partitions ) ) + { + QVariantMap pMap; + if ( partition.canConvert< QVariantMap >() ) + { + pMap = partition.toMap(); + } + + // If it isn't a zfs partition, ignore it + if ( pMap[ "fsName" ] != "zfs" ) + { + continue; + } + + // Find the best device identifier, if one isn't available, skip this partition + QString deviceName = findBestZfsDevice( pMap ); + if ( deviceName.isEmpty() ) + { + continue; + } + + // If the partition doesn't have a mountpoint, skip it + QString mountpoint = pMap[ "mountPoint" ].toString(); + if ( mountpoint.isEmpty() ) + { + continue; + } + + // Build a poolname off config pool name and the mountpoint, this is not ideal but should work until there is UI built for zfs + QString poolName = m_poolName; + if ( mountpoint != '/' ) + { + poolName += alphaNumeric( mountpoint ); + } + + // Look in the zfs info list to see if this partition should be encrypted + bool encrypt = false; + QString passphrase; + for ( const QVariant& zfsInfo : std::as_const( zfsInfoList ) ) + { + if ( zfsInfo.canConvert< QVariantMap >() && zfsInfo.toMap().value( "encrypted" ).toBool() + && mountpoint == zfsInfo.toMap().value( "mountpoint" ) ) + { + encrypt = true; + passphrase = zfsInfo.toMap().value( "passphrase" ).toString(); + } + } + + // Generate the zfs hostid file + auto i = system->runCommand( { "zgenhostid" }, std::chrono::seconds( 3 ) ); + if ( i.getExitCode() != 0 ) + { + cWarning() << "Failed to create /etc/hostid"; + } + + // Create the zpool + ZfsResult zfsResult; + if ( encrypt ) + { + zfsResult = createZpool( deviceName, poolName, m_poolOptions, true, passphrase ); + } + else + { + zfsResult = createZpool( deviceName, poolName, m_poolOptions, false ); + } + + if ( !zfsResult.success ) + { + return Calamares::JobResult::error( tr( "Failed to create zpool" ), zfsResult.failureMessage ); + } + + // Save the poolname, dataset name and mountpoint. It will later be added to a list and placed in global storage. + // This will be used by later modules including mount and umount + QVariantMap poolNameEntry; + poolNameEntry[ "poolName" ] = poolName; + poolNameEntry[ "mountpoint" ] = mountpoint; + poolNameEntry[ "dsName" ] = "none"; + + // If the mountpoint is /, create datasets per the config file. If not, create a single dataset mounted at the partitions mountpoint + if ( mountpoint == '/' ) + { + collectMountpoints( partitions ); + QVariantList datasetList; + for ( const auto& dataset : std::as_const( m_datasets ) ) + { + QVariantMap datasetMap = dataset.toMap(); + + // Make sure all values are valid + if ( datasetMap[ "dsName" ].toString().isEmpty() || datasetMap[ "mountpoint" ].toString().isEmpty() + || datasetMap[ "canMount" ].toString().isEmpty() ) + { + cWarning() << "Bad dataset entry"; + continue; + } + + // We should skip this dataset if it conflicts with a permanent mountpoint + if ( isMountpointOverlapping( datasetMap[ "mountpoint" ].toString() ) ) + { + continue; + } + + QString canMount = convertCanMount( datasetMap[ "canMount" ].toString() ); + + // Create the dataset + auto r = system->runCommand( { QStringList() << "zfs" + << "create" << m_datasetOptions.split( ' ' ) << "-o" + << "canmount=" + canMount << "-o" + << "mountpoint=" + datasetMap[ "mountpoint" ].toString() + << poolName + "/" + datasetMap[ "dsName" ].toString() }, + std::chrono::seconds( 10 ) ); + if ( r.getExitCode() != 0 ) + { + cWarning() << "Failed to create dataset" << datasetMap[ "dsName" ].toString(); + continue; + } + + // Add the dataset to the list for global storage this information is used later to properly set + // the mount options on each dataset + datasetMap[ "zpool" ] = m_poolName; + datasetList.append( datasetMap ); + } + + // If the list isn't empty, add it to global storage + if ( !datasetList.isEmpty() ) + { + gs->insert( "zfsDatasets", datasetList ); + } + } + else + { + QString dsName = mountpoint; + dsName = alphaNumeric( mountpoint ); + auto r = system->runCommand( { QStringList() << "zfs" + << "create" << m_datasetOptions.split( ' ' ) << "-o" + << "canmount=on" + << "-o" + << "mountpoint=" + mountpoint << poolName + "/" + dsName }, + std::chrono::seconds( 10 ) ); + if ( r.getExitCode() != 0 ) + { + return Calamares::JobResult::error( tr( "Failed to create dataset" ), + tr( "The output was: " ) + r.getOutput() ); + } + poolNameEntry[ "dsName" ] = dsName; + } + + poolNames.append( poolNameEntry ); + + // Export the zpool so it can be reimported at the correct location later + auto r = system->runCommand( { "zpool", "export", poolName }, std::chrono::seconds( 10 ) ); + if ( r.getExitCode() != 0 ) + { + cWarning() << "Failed to export pool" << m_poolName; + } + } + + // Put the list of zpools into global storage + if ( !poolNames.isEmpty() ) + { + gs->insert( "zfsPoolInfo", poolNames ); + } + + return Calamares::JobResult::ok(); +} + +void +ZfsJob::setConfigurationMap( const QVariantMap& map ) +{ + m_poolName = Calamares::getString( map, "poolName" ); + m_poolOptions = Calamares::getString( map, "poolOptions" ); + m_datasetOptions = Calamares::getString( map, "datasetOptions" ); + + m_datasets = Calamares::getList( map, "datasets" ); +} + +CALAMARES_PLUGIN_FACTORY_DEFINITION( ZfsJobFactory, registerPlugin< ZfsJob >(); ) diff --git a/calamares/src/modules/zfs/ZfsJob.h b/calamares/src/modules/zfs/ZfsJob.h new file mode 100644 index 0000000..58a6450 --- /dev/null +++ b/calamares/src/modules/zfs/ZfsJob.h @@ -0,0 +1,89 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2021 Evan James + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +#ifndef ZFSJOB_H +#define ZFSJOB_H + +#include +#include +#include + +#include "CppJob.h" + +#include "utils/PluginFactory.h" + +#include "DllMacro.h" + +struct ZfsResult +{ + bool success; + QString failureMessage; // This message is displayed to the user and should be translated at the time of population +}; + +/** @brief Create zpools and zfs datasets + * + */ +class PLUGINDLLEXPORT ZfsJob : public Calamares::CppJob +{ + Q_OBJECT + +public: + explicit ZfsJob( QObject* parent = nullptr ); + ~ZfsJob() override; + + QString prettyName() const override; + + Calamares::JobResult exec() override; + + void setConfigurationMap( const QVariantMap& configurationMap ) override; + +private: + QString m_poolName; + QString m_poolOptions; + QString m_datasetOptions; + QStringList m_mountpoints; + + QList< QVariant > m_datasets; + + /** @brief Creates a zpool based on the provided arguments + * + * @p deviceName is a full path to the device the zpool should be created on + * @p poolName is a string containing the name of the pool to create + * @p poolOptions are the options to pass to zpool create + * @p encrypt is a boolean which determines if the pool should be encrypted + * @p passphrase is a string continaing the passphrase + * + */ + ZfsResult createZpool( QString deviceName, + QString poolName, + QString poolOptions, + bool encrypt, + QString passphrase = QString() ) const; + + /** @brief Collects all the mountpoints from the partitions + * + * Iterates over @p partitions to gather each mountpoint present + * in the list of maps and populates m_mountpoints + * + */ + void collectMountpoints( const QVariantList& partitions ); + + /** @brief Check to see if a given mountpoint overlaps with one of the defined moutnpoints + * + * Iterates over m_partitions and checks if @p targetMountpoint overlaps with them by comparing + * the beginning of targetMountpoint with all the values in m_mountpoints. Of course, / is excluded + * since all the mountpoints would begin with / + * + */ + bool isMountpointOverlapping( const QString& targetMountpoint ) const; +}; + +CALAMARES_PLUGIN_FACTORY_DECLARATION( ZfsJobFactory ) + +#endif // ZFSJOB_H diff --git a/calamares/src/modules/zfs/zfs.conf b/calamares/src/modules/zfs/zfs.conf new file mode 100644 index 0000000..e5a0aa3 --- /dev/null +++ b/calamares/src/modules/zfs/zfs.conf @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +# +# The zfs module creates the zfs pools and datasets +# +# +# +--- +# The name to be used for the zpool +poolName: zpcala + +# A list of options that will be passed to zpool create +# +# Encryption options should generally not be added here since they will be added by +# selecting the encrypt disk option in the partition module +poolOptions: "-f -o ashift=12 -O mountpoint=none -O acltype=posixacl -O relatime=on" + +# A list of options that will be passed to zfs create when creating each dataset +# Do not include "canmount" or "mountpoint" as those are set below in the datasets array +datasetOptions: "-o compression=lz4 -o atime=off -o xattr=sa" + +# An array of datasets that will be created on the zpool mounted at / +# +# This default configuration is commonly used when support for booting more than one distro +# out of a single zpool is desired. If you decide to keep this default configuration, +# you should replace "distro" with an identifier that represents your distro. +datasets: + - dsName: ROOT + mountpoint: none + canMount: off + - dsName: ROOT/distro + mountpoint: none + canMount: off + - dsName: ROOT/distro/root + mountpoint: / + canMount: noauto + - dsName: ROOT/distro/home + mountpoint: /home + canMount: on + - dsName: ROOT/distro/varcache + mountpoint: /var/cache + canMount: on + - dsName: ROOT/distro/varlog + mountpoint: /var/log + canMount: on diff --git a/calamares/src/modules/zfs/zfs.schema.yaml b/calamares/src/modules/zfs/zfs.schema.yaml new file mode 100644 index 0000000..ddad6d7 --- /dev/null +++ b/calamares/src/modules/zfs/zfs.schema.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/zfs +additionalProperties: false +type: object +properties: + poolName: { type: string } + poolOptions: { type: string } + datasetOptions: { type: string } + datasets: + type: array + items: + type: object + additionalProperties: false + properties: + dsName: { type: string } + mountpoint: { type: string } + # Nominally a string, but "on" and "off" are valid and get + # turned into a boolean in the YAML parser. + canMount: { anyOf: [ { type: string }, { type: boolean } ] } + required: [ dsName, mountpoint, canMount ] +required: [ poolName, datasets ] diff --git a/calamares/src/modules/zfshostid/main.py b/calamares/src/modules/zfshostid/main.py new file mode 100644 index 0000000..702ba1e --- /dev/null +++ b/calamares/src/modules/zfshostid/main.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2022 Anke Boersma +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Calamares is Free Software: see the License-Identifier above. +# + +import os +import shutil + +import libcalamares +from libcalamares.utils import gettext_path, gettext_languages + + +import gettext +_ = gettext.translation("calamares-python", + localedir=libcalamares.utils.gettext_path(), + languages=libcalamares.utils.gettext_languages(), + fallback=True).gettext + + +def pretty_name(): + return _("Copying zfs generated hostid.") + + +def run(): + + zfs = libcalamares.globalstorage.value("zfsDatasets") + root_mount_point = libcalamares.globalstorage.value("rootMountPoint") + + if zfs: + hostid_source = '/etc/hostid' + hostid_destination = '{!s}/etc/hostid'.format(root_mount_point) + + try: + shutil.copy2(hostid_source, hostid_destination) + except Exception as e: + libcalamares.utils.warning("Could not copy hostid") + + return None diff --git a/calamares/src/modules/zfshostid/module.desc b/calamares/src/modules/zfshostid/module.desc new file mode 100644 index 0000000..13fec35 --- /dev/null +++ b/calamares/src/modules/zfshostid/module.desc @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: no +# SPDX-License-Identifier: CC0-1.0 +--- +type: "job" +name: "zfshostid" +interface: "python" +script: "main.py" +noconfig: true diff --git a/calamares/src/modules/zfshostid/zfshostid.schema.yaml b/calamares/src/modules/zfshostid/zfshostid.schema.yaml new file mode 100644 index 0000000..24774d0 --- /dev/null +++ b/calamares/src/modules/zfshostid/zfshostid.schema.yaml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2022 Anke Boersma +# SPDX-License-Identifier: GPL-3.0-or-later +--- +$schema: https://json-schema.org/schema# +$id: https://calamares.io/schemas/zfshostid +additionalProperties: false +type: object diff --git a/calamares/src/qml/CMakeLists.txt b/calamares/src/qml/CMakeLists.txt new file mode 100644 index 0000000..fa0ee8f --- /dev/null +++ b/calamares/src/qml/CMakeLists.txt @@ -0,0 +1,50 @@ +# === This file is part of Calamares - === +# +# SPDX-FileCopyrightText: 2020 Adriaan de Groot +# SPDX-License-Identifier: BSD-2-Clause +# + +# Install "slideshows" and other QML-sources for Calamares. +# +# In practice, in the central source repositoy, this means +# just-install-the-slideshow-example. For alternative slideshows, +# see the approach in the calamares-extensions repository. + +function(calamares_install_qml) + # Iterate over all the subdirectories which have a qmldir file, copy them over to the build dir, + # and install them into share/calamares/qml/calamares + file(GLOB SUBDIRECTORIES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR}/${qml_subdir} "*") + foreach(SUBDIRECTORY ${SUBDIRECTORIES}) + if( + IS_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/${SUBDIRECTORY}" + AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${SUBDIRECTORY}/qmldir" + ) + set(QML_DIR share/calamares/qml) + set(QML_MODULE_DESTINATION ${QML_DIR}/calamares/${SUBDIRECTORY}) + + # We glob all the files inside the subdirectory, and we make sure they are + # synced with the bindir structure and installed. + file(GLOB QML_MODULE_FILES RELATIVE ${CMAKE_CURRENT_SOURCE_DIR}/${SUBDIRECTORY} "${SUBDIRECTORY}/*") + foreach(QML_MODULE_FILE ${QML_MODULE_FILES}) + if(NOT IS_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/${SUBDIRECTORY}/${QML_MODULE_FILE}) + configure_file(${SUBDIRECTORY}/${QML_MODULE_FILE} ${SUBDIRECTORY}/${QML_MODULE_FILE} COPYONLY) + + install( + FILES ${CMAKE_CURRENT_BINARY_DIR}/${SUBDIRECTORY}/${QML_MODULE_FILE} + DESTINATION ${QML_MODULE_DESTINATION} + ) + endif() + endforeach() + + message("-- ${BoldYellow}Configured QML module: ${BoldRed}calamares.${SUBDIRECTORY}${ColorReset}") + endif() + endforeach() + + message("") +endfunction() + +if(WITH_QT6) + add_subdirectory(calamares-qt6) +else() + add_subdirectory(calamares-qt5) +endif() diff --git a/calamares/src/qml/calamares-qt5/CMakeLists.txt b/calamares/src/qml/calamares-qt5/CMakeLists.txt new file mode 100644 index 0000000..456f967 --- /dev/null +++ b/calamares/src/qml/calamares-qt5/CMakeLists.txt @@ -0,0 +1 @@ +calamares_install_qml() diff --git a/calamares/src/qml/calamares-qt5/slideshow/BackButton.qml b/calamares/src/qml/calamares-qt5/slideshow/BackButton.qml new file mode 100644 index 0000000..4e420e0 --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/BackButton.qml @@ -0,0 +1,15 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +NavButton { + id: backButton + anchors.left: parent.left + visible: parent.currentSlide > 0 + isForward: false +} diff --git a/calamares/src/qml/calamares-qt5/slideshow/ForwardButton.qml b/calamares/src/qml/calamares-qt5/slideshow/ForwardButton.qml new file mode 100644 index 0000000..7838fab --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/ForwardButton.qml @@ -0,0 +1,14 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +NavButton { + id: forwardButton + anchors.right: parent.right + visible: parent.currentSlide + 1 < parent.slides.length; +} diff --git a/calamares/src/qml/calamares-qt5/slideshow/NavButton.qml b/calamares/src/qml/calamares-qt5/slideshow/NavButton.qml new file mode 100644 index 0000000..bdb2f40 --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/NavButton.qml @@ -0,0 +1,59 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* This is a navigation (arrow) button that fades in on hover, and + * which calls forward / backward navigation on the presentation it + * is in. It should be a child item of the presentation (not of a + * single slide). Use the ForwardButton or BackButton for a pre- + * configured instance that interacts with the presentation. + */ + +import QtQuick 2.5; + +Image { + id: fade + + property bool isForward : true + + width: 100 + height: 100 + anchors.verticalCenter: parent.verticalCenter + opacity: 0.3 + + OpacityAnimator { + id: fadeIn + target: fade + from: fade.opacity + to: 1.0 + duration: 500 + running: false + } + + OpacityAnimator { + id: fadeOut + target: fade + from: fade.opacity + to: 0.3 + duration: 250 + running: false + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: { fadeOut.running = false; fadeIn.running = true } + onExited: { fadeIn.running = false ; fadeOut.running = true } + onClicked: { + if (isForward) + fade.parent.goToNextSlide() + else + fade.parent.goToPreviousSlide() + } + } +} diff --git a/calamares/src/qml/calamares-qt5/slideshow/Presentation.qml b/calamares/src/qml/calamares-qt5/slideshow/Presentation.qml new file mode 100644 index 0000000..1eed2e8 --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/Presentation.qml @@ -0,0 +1,243 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-FileCopyrightText: 2016 The Qt Company Ltd. + * SPDX-License-Identifier: LGPL-2.1-only + * + * 2017, Adriaan de Groot + * - added looping, keys-instead-of-shortcut + * 2018, Adriaan de Groot + * - make looping a property, drop the 'c' fade-key + * - drop navigation through entering a slide number + * (this and the 'c' key make sense in a *presentation* + * slideshow, not in a passive slideshow like Calamares) + * - remove quit key + * 2019, Adriaan de Groot + * - Support "V2" loading + * - Disable shortcuts until the content is visible in Calamares + * 2020, Adriaan de Groot + * - Updated to SPDX headers + */ + +/**************************************************************************** +** +** Copyright (C) 2016 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of the QML Presentation System. +** +** $QT_BEGIN_LICENSE:LGPL$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and Digia. For licensing terms and +** conditions see http://qt.digia.com/licensing. For further information +** use the contact form at http://qt.digia.com/contact-us. +** +** GNU Lesser General Public License Usage +** Alternatively, this file may be used under the terms of the GNU Lesser +** General Public License version 2.1 as published by the Free Software +** Foundation and appearing in the file LICENSE.LGPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU Lesser General Public License version 2.1 requirements +** will be met: http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html. +** +** In addition, as a special exception, Digia gives you certain additional +** rights. These rights are described in the Digia Qt LGPL Exception +** version 1.1, included in the file LGPL_EXCEPTION.txt in this package. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3.0 as published by the Free Software +** Foundation and appearing in the file LICENSE.GPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU General Public License version 3.0 requirements will be +** met: http://www.gnu.org/copyleft/gpl.html. +** +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + + +import QtQuick 2.5 +import QtQuick.Window 2.0 + +Item { + id: root + + property variant slides: [] + property int currentSlide: 0 + + property bool loopSlides: true + + property bool showNotes: false; + property bool allowDelay: true; + property alias mouseNavigation: mouseArea.enabled + property bool arrowNavigation: true + property bool keyShortcutsEnabled: true + + property color titleColor: textColor; + property color textColor: "black" + property string fontFamily: "Helvetica" + property string codeFontFamily: "Courier New" + + // This is set by the C++ part of Calamares when the slideshow + // becomes visible. You can connect it to a timer, or whatever + // else needs to start only when the slideshow becomes visible. + // + // It is used in this example also to keep the keyboard shortcuts + // enabled only while the slideshow is active. + property bool activatedInCalamares: false + + // Private API + property int _lastShownSlide: 0 + + Component.onCompleted: { + var slideCount = 0; + var slides = []; + for (var i=0; i 0) + root.slides[root.currentSlide].visible = true; + } + + function switchSlides(from, to, forward) { + from.visible = false + to.visible = true + return true + } + + onCurrentSlideChanged: { + switchSlides(root.slides[_lastShownSlide], root.slides[currentSlide], currentSlide > _lastShownSlide) + _lastShownSlide = currentSlide + // Always keep focus on the slideshow + root.focus = true + } + + function goToNextSlide() { + if (root.slides[currentSlide].delayPoints) { + if (root.slides[currentSlide]._advance()) + return; + } + if (currentSlide + 1 < root.slides.length) + ++currentSlide; + else if (loopSlides) + currentSlide = 0; // Loop at the end + } + + function goToPreviousSlide() { + if (currentSlide - 1 >= 0) + --currentSlide; + else if (loopSlides) + currentSlide = root.slides.length - 1 + } + + focus: true // Keep focus + + // Navigation through key events, too + Keys.onSpacePressed: goToNextSlide() + Keys.onRightPressed: goToNextSlide() + Keys.onLeftPressed: goToPreviousSlide() + + // navigate with arrow keys + Shortcut { sequence: StandardKey.MoveToNextLine; enabled: root.activatedInCalamares && root .arrowNavigation; onActivated: goToNextSlide() } + Shortcut { sequence: StandardKey.MoveToPreviousLine; enabled: root.activatedInCalamares && root.arrowNavigation; onActivated: goToPreviousSlide() } + Shortcut { sequence: StandardKey.MoveToNextChar; enabled: root.activatedInCalamares && root.arrowNavigation; onActivated: goToNextSlide() } + Shortcut { sequence: StandardKey.MoveToPreviousChar; enabled: root.activatedInCalamares && root.arrowNavigation; onActivated: goToPreviousSlide() } + + // presentation-specific single-key shortcuts (which interfere with normal typing) + Shortcut { sequence: " "; enabled: root.activatedInCalamares && root.keyShortcutsEnabled; onActivated: goToNextSlide() } + + // standard shortcuts + Shortcut { sequence: StandardKey.MoveToNextPage; enabled: root.activatedInCalamares; onActivated: goToNextSlide() } + Shortcut { sequence: StandardKey.MoveToPreviousPage; enabled: root.activatedInCalamares; onActivated: goToPreviousSlide() } + + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: { + if (mouse.button == Qt.RightButton) + goToPreviousSlide() + else + goToNextSlide() + } + onPressAndHold: goToPreviousSlide(); //A back mechanism for touch only devices + } + + Window { + id: notesWindow; + width: 400 + height: 300 + + title: "QML Presentation: Notes" + visible: root.showNotes + + Flickable { + anchors.fill: parent + contentWidth: parent.width + contentHeight: textContainer.height + + Item { + id: textContainer + width: parent.width + height: notesText.height + 2 * notesText.padding + + Text { + id: notesText + + property real padding: 16; + + x: padding + y: padding + width: parent.width - 2 * padding + + + font.pixelSize: 16 + wrapMode: Text.WordWrap + + property string notes: root.slides[root.currentSlide].notes; + + onNotesChanged: { + var result = ""; + + var lines = notes.split("\n"); + var beginNewLine = false + for (var i=0; i 0) + result += " "; + result += line; + } + } + + if (result.length == 0) { + font.italic = true; + text = "no notes.." + } else { + font.italic = false; + text = result; + } + } + } + } + } + } +} diff --git a/calamares/src/qml/calamares-qt5/slideshow/Slide.qml b/calamares/src/qml/calamares-qt5/slideshow/Slide.qml new file mode 100644 index 0000000..9cb9e73 --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/Slide.qml @@ -0,0 +1,206 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2012 Digia Plc and/or its subsidiary(-ies). + * SPDX-License-Identifier: LGPL-2.1-only + */ + +/**************************************************************************** +** +** Copyright (C) 2012 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the QML Presentation System. +** +** $QT_BEGIN_LICENSE:LGPL$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and Digia. For licensing terms and +** conditions see http://qt.digia.com/licensing. For further information +** use the contact form at http://qt.digia.com/contact-us. +** +** GNU Lesser General Public License Usage +** Alternatively, this file may be used under the terms of the GNU Lesser +** General Public License version 2.1 as published by the Free Software +** Foundation and appearing in the file LICENSE.LGPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU Lesser General Public License version 2.1 requirements +** will be met: http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html. +** +** In addition, as a special exception, Digia gives you certain additional +** rights. These rights are described in the Digia Qt LGPL Exception +** version 1.1, included in the file LGPL_EXCEPTION.txt in this package. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3.0 as published by the Free Software +** Foundation and appearing in the file LICENSE.GPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU General Public License version 3.0 requirements will be +** met: http://www.gnu.org/copyleft/gpl.html. +** +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + + +import QtQuick 2.5 + +Item { + /* + Slides can only be instantiated as a direct child of a Presentation {} as they rely on + several properties there. + */ + + id: slide + + property bool isSlide: true; + + property bool delayPoints: false; + property int _pointCounter: 0; + function _advance() { + if (!parent.allowDelay) + return false; + + _pointCounter = _pointCounter + 1; + if (_pointCounter < content.length) + return true; + _pointCounter = 0; + return false; + } + + property string title; + property variant content: [] + property string centeredText + property string writeInText; + property string notes; + + property real fontSize: parent.height * 0.05 + property real fontScale: 1 + + property real baseFontSize: fontSize * fontScale + property real titleFontSize: fontSize * 1.2 * fontScale + property real bulletSpacing: 1 + + property real contentWidth: width + + // Define the slide to be the "content area" + x: parent.width * 0.05 + y: parent.height * 0.2 + width: parent.width * 0.9 + height: parent.height * 0.7 + + property real masterWidth: parent.width + property real masterHeight: parent.height + + property color titleColor: parent.titleColor; + property color textColor: parent.textColor; + property string fontFamily: parent.fontFamily; + property int textFormat: Text.PlainText + + visible: false + + Text { + id: titleText + font.pixelSize: titleFontSize + text: title; + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.top + anchors.bottomMargin: parent.fontSize * 1.5 + font.bold: true; + font.family: slide.fontFamily + color: slide.titleColor + horizontalAlignment: Text.Center + z: 1 + } + + Text { + id: centeredId + width: parent.width + anchors.centerIn: parent + anchors.verticalCenterOffset: - parent.y / 3 + text: centeredText + horizontalAlignment: Text.Center + font.pixelSize: baseFontSize + font.family: slide.fontFamily + color: slide.textColor + wrapMode: Text.Wrap + } + + Text { + id: writeInTextId + property int length; + font.family: slide.fontFamily + font.pixelSize: baseFontSize + color: slide.textColor + + anchors.fill: parent; + wrapMode: Text.Wrap + + text: slide.writeInText.substring(0, length); + + NumberAnimation on length { + from: 0; + to: slide.writeInText.length; + duration: slide.writeInText.length * 30; + running: slide.visible && parent.visible && slide.writeInText.length > 0 + } + + visible: slide.writeInText != undefined; + } + + + Column { + id: contentId + anchors.fill: parent + + Repeater { + model: content.length + + Row { + id: row + + function decideIndentLevel(s) { return s.charAt(0) == " " ? 1 + decideIndentLevel(s.substring(1)) : 0 } + property int indentLevel: decideIndentLevel(content[index]) + property int nextIndentLevel: index < content.length - 1 ? decideIndentLevel(content[index+1]) : 0 + property real indentFactor: (10 - row.indentLevel * 2) / 10; + + height: text.height + (nextIndentLevel == 0 ? 1 : 0.3) * slide.baseFontSize * slide.bulletSpacing + x: slide.baseFontSize * indentLevel + visible: (!slide.parent.allowDelay || !delayPoints) || index <= _pointCounter + + Rectangle { + id: dot + anchors.baseline: text.baseline + anchors.baselineOffset: -text.font.pixelSize / 2 + width: text.font.pixelSize / 3 + height: text.font.pixelSize / 3 + color: slide.textColor + radius: width / 2 + opacity: text.text.length == 0 ? 0 : 1 + } + + Item { + id: space + width: dot.width * 1.5 + height: 1 + } + + Text { + id: text + width: slide.contentWidth - parent.x - dot.width - space.width + font.pixelSize: baseFontSize * row.indentFactor + text: content[index] + textFormat: slide.textFormat + wrapMode: Text.WordWrap + color: slide.textColor + horizontalAlignment: Text.AlignLeft + font.family: slide.fontFamily + } + } + } + } + +} diff --git a/calamares/src/qml/calamares-qt5/slideshow/SlideCounter.qml b/calamares/src/qml/calamares-qt5/slideshow/SlideCounter.qml new file mode 100644 index 0000000..d5b2de7 --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/SlideCounter.qml @@ -0,0 +1,29 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* This control just shows a (non-translated) count of the slides + * in the slideshow in the format "n / total". + */ + +import QtQuick 2.5; + +Rectangle { + id: slideCounter + anchors.right: parent.right + anchors.bottom: parent.bottom + width: 100 + height: 50 + + Text { + id: slideCounterText + anchors.centerIn: parent + //: slide counter, %1 of %2 (numeric) + text: qsTr("%L1 / %L2").arg(parent.parent.currentSlide + 1).arg(parent.parent.slides.length) + } +} diff --git a/calamares/src/qml/calamares-qt5/slideshow/qmldir b/calamares/src/qml/calamares-qt5/slideshow/qmldir new file mode 100644 index 0000000..7b964b8 --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/qmldir @@ -0,0 +1,10 @@ +module calamares.slideshow + +Presentation 1.0 Presentation.qml +Slide 1.0 Slide.qml + +NavButton 1.0 NavButton.qml +ForwardButton 1.0 ForwardButton.qml +BackButton 1.0 BackButton.qml + +SlideCounter 1.0 SlideCounter.qml diff --git a/calamares/src/qml/calamares-qt5/slideshow/qmldir.license b/calamares/src/qml/calamares-qt5/slideshow/qmldir.license new file mode 100644 index 0000000..d2da9cf --- /dev/null +++ b/calamares/src/qml/calamares-qt5/slideshow/qmldir.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: no +SPDX-License-Identifier: CC0-1.0 diff --git a/calamares/src/qml/calamares-qt6/CMakeLists.txt b/calamares/src/qml/calamares-qt6/CMakeLists.txt new file mode 100644 index 0000000..456f967 --- /dev/null +++ b/calamares/src/qml/calamares-qt6/CMakeLists.txt @@ -0,0 +1 @@ +calamares_install_qml() diff --git a/calamares/src/qml/calamares-qt6/slideshow/BackButton.qml b/calamares/src/qml/calamares-qt6/slideshow/BackButton.qml new file mode 100644 index 0000000..4e420e0 --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/BackButton.qml @@ -0,0 +1,15 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +NavButton { + id: backButton + anchors.left: parent.left + visible: parent.currentSlide > 0 + isForward: false +} diff --git a/calamares/src/qml/calamares-qt6/slideshow/ForwardButton.qml b/calamares/src/qml/calamares-qt6/slideshow/ForwardButton.qml new file mode 100644 index 0000000..7838fab --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/ForwardButton.qml @@ -0,0 +1,14 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +NavButton { + id: forwardButton + anchors.right: parent.right + visible: parent.currentSlide + 1 < parent.slides.length; +} diff --git a/calamares/src/qml/calamares-qt6/slideshow/NavButton.qml b/calamares/src/qml/calamares-qt6/slideshow/NavButton.qml new file mode 100644 index 0000000..bdb2f40 --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/NavButton.qml @@ -0,0 +1,59 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* This is a navigation (arrow) button that fades in on hover, and + * which calls forward / backward navigation on the presentation it + * is in. It should be a child item of the presentation (not of a + * single slide). Use the ForwardButton or BackButton for a pre- + * configured instance that interacts with the presentation. + */ + +import QtQuick 2.5; + +Image { + id: fade + + property bool isForward : true + + width: 100 + height: 100 + anchors.verticalCenter: parent.verticalCenter + opacity: 0.3 + + OpacityAnimator { + id: fadeIn + target: fade + from: fade.opacity + to: 1.0 + duration: 500 + running: false + } + + OpacityAnimator { + id: fadeOut + target: fade + from: fade.opacity + to: 0.3 + duration: 250 + running: false + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: { fadeOut.running = false; fadeIn.running = true } + onExited: { fadeIn.running = false ; fadeOut.running = true } + onClicked: { + if (isForward) + fade.parent.goToNextSlide() + else + fade.parent.goToPreviousSlide() + } + } +} diff --git a/calamares/src/qml/calamares-qt6/slideshow/Presentation.qml b/calamares/src/qml/calamares-qt6/slideshow/Presentation.qml new file mode 100644 index 0000000..7abc14f --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/Presentation.qml @@ -0,0 +1,238 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2017 Adriaan de Groot + * SPDX-FileCopyrightText: 2016 The Qt Company Ltd. + * SPDX-License-Identifier: LGPL-2.1-only + * + * 2017, Adriaan de Groot + * - added looping, keys-instead-of-shortcut + * 2018, Adriaan de Groot + * - make looping a property, drop the 'c' fade-key + * - drop navigation through entering a slide number + * (this and the 'c' key make sense in a *presentation* + * slideshow, not in a passive slideshow like Calamares) + * - remove quit key + * 2019, Adriaan de Groot + * - Support "V2" loading + * - Disable shortcuts until the content is visible in Calamares + * 2020, Adriaan de Groot + * - Updated to SPDX headers + */ + +/**************************************************************************** +** +** Copyright (C) 2016 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of the QML Presentation System. +** +** $QT_BEGIN_LICENSE:LGPL$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and Digia. For licensing terms and +** conditions see http://qt.digia.com/licensing. For further information +** use the contact form at http://qt.digia.com/contact-us. +** +** GNU Lesser General Public License Usage +** Alternatively, this file may be used under the terms of the GNU Lesser +** General Public License version 2.1 as published by the Free Software +** Foundation and appearing in the file LICENSE.LGPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU Lesser General Public License version 2.1 requirements +** will be met: http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html. +** +** In addition, as a special exception, Digia gives you certain additional +** rights. These rights are described in the Digia Qt LGPL Exception +** version 1.1, included in the file LGPL_EXCEPTION.txt in this package. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3.0 as published by the Free Software +** Foundation and appearing in the file LICENSE.GPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU General Public License version 3.0 requirements will be +** met: http://www.gnu.org/copyleft/gpl.html. +** +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + + +import QtQuick 2.5 +import QtQuick.Window 2.0 + +Item { + id: root + + property variant slides: [] + property int currentSlide: 0 + + property bool loopSlides: true + + property bool showNotes: false; + property bool allowDelay: true; + property alias mouseNavigation: mouseArea.enabled + property bool arrowNavigation: true + property bool keyShortcutsEnabled: true + + property color titleColor: textColor; + property color textColor: "black" + property string fontFamily: "Helvetica" + property string codeFontFamily: "Courier New" + + // This is set by the C++ part of Calamares when the slideshow + // becomes visible. You can connect it to a timer, or whatever + // else needs to start only when the slideshow becomes visible. + // + // It is used in this example also to keep the keyboard shortcuts + // enabled only while the slideshow is active. + property bool activatedInCalamares: false + + // Private API + property int _lastShownSlide: 0 + + Component.onCompleted: { + var slideCount = 0; + var slides = []; + for (var i=0; i 0) + root.slides[root.currentSlide].visible = true; + } + + function switchSlides(from, to, forward) { + from.visible = false + to.visible = true + return true + } + + onCurrentSlideChanged: { + switchSlides(root.slides[_lastShownSlide], root.slides[currentSlide], currentSlide > _lastShownSlide) + _lastShownSlide = currentSlide + // Always keep focus on the slideshow + root.focus = true + } + + function goToNextSlide() { + if (root.slides[currentSlide].delayPoints) { + if (root.slides[currentSlide]._advance()) + return; + } + if (currentSlide + 1 < root.slides.length) + ++currentSlide; + else if (loopSlides) + currentSlide = 0; // Loop at the end + } + + function goToPreviousSlide() { + if (currentSlide - 1 >= 0) + --currentSlide; + else if (loopSlides) + currentSlide = root.slides.length - 1 + } + + focus: true // Keep focus + + // Navigation through key events, too + Keys.onSpacePressed: goToNextSlide() + Keys.onRightPressed: goToNextSlide() + Keys.onLeftPressed: goToPreviousSlide() + + // navigate with arrow keys + Shortcut { sequence: StandardKey.MoveToNextLine; enabled: root.activatedInCalamares && root .arrowNavigation; onActivated: goToNextSlide() } + Shortcut { sequence: StandardKey.MoveToPreviousLine; enabled: root.activatedInCalamares && root.arrowNavigation; onActivated: goToPreviousSlide() } + Shortcut { sequence: StandardKey.MoveToNextChar; enabled: root.activatedInCalamares && root.arrowNavigation; onActivated: goToNextSlide() } + Shortcut { sequence: StandardKey.MoveToPreviousChar; enabled: root.activatedInCalamares && root.arrowNavigation; onActivated: goToPreviousSlide() } + + // presentation-specific single-key shortcuts (which interfere with normal typing) + Shortcut { sequence: " "; enabled: root.activatedInCalamares && root.keyShortcutsEnabled; onActivated: goToNextSlide() } + + // standard shortcuts + Shortcut { sequence: StandardKey.MoveToNextPage; enabled: root.activatedInCalamares; onActivated: goToNextSlide() } + Shortcut { sequence: StandardKey.MoveToPreviousPage; enabled: root.activatedInCalamares; onActivated: goToPreviousSlide() } + + MouseArea { + id: mouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: { + if (mouse.button == Qt.RightButton) + goToPreviousSlide() + else + goToNextSlide() + } + onPressAndHold: goToPreviousSlide(); //A back mechanism for touch only devices + } + + Window { + id: notesWindow; + width: 400 + height: 300 + + title: "QML Presentation: Notes" + visible: root.showNotes + + Flickable { + anchors.fill: parent + contentWidth: parent.width + contentHeight: textContainer.height + + Item { + id: textContainer + width: parent.width + height: notesText.height + 2 * notesText.padding + + Text { + id: notesText + + anchors.margins: 16 + + font.pixelSize: 16 + wrapMode: Text.WordWrap + + property string notes: root.slides[root.currentSlide].notes; + + onNotesChanged: { + var result = ""; + + var lines = notes.split("\n"); + var beginNewLine = false + for (var i=0; i 0) + result += " "; + result += line; + } + } + + if (result.length == 0) { + font.italic = true; + text = "no notes.." + } else { + font.italic = false; + text = result; + } + } + } + } + } + } +} diff --git a/calamares/src/qml/calamares-qt6/slideshow/Slide.qml b/calamares/src/qml/calamares-qt6/slideshow/Slide.qml new file mode 100644 index 0000000..9cb9e73 --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/Slide.qml @@ -0,0 +1,206 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2012 Digia Plc and/or its subsidiary(-ies). + * SPDX-License-Identifier: LGPL-2.1-only + */ + +/**************************************************************************** +** +** Copyright (C) 2012 Digia Plc and/or its subsidiary(-ies). +** Contact: http://www.qt-project.org/legal +** +** This file is part of the QML Presentation System. +** +** $QT_BEGIN_LICENSE:LGPL$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and Digia. For licensing terms and +** conditions see http://qt.digia.com/licensing. For further information +** use the contact form at http://qt.digia.com/contact-us. +** +** GNU Lesser General Public License Usage +** Alternatively, this file may be used under the terms of the GNU Lesser +** General Public License version 2.1 as published by the Free Software +** Foundation and appearing in the file LICENSE.LGPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU Lesser General Public License version 2.1 requirements +** will be met: http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html. +** +** In addition, as a special exception, Digia gives you certain additional +** rights. These rights are described in the Digia Qt LGPL Exception +** version 1.1, included in the file LGPL_EXCEPTION.txt in this package. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3.0 as published by the Free Software +** Foundation and appearing in the file LICENSE.GPL included in the +** packaging of this file. Please review the following information to +** ensure the GNU General Public License version 3.0 requirements will be +** met: http://www.gnu.org/copyleft/gpl.html. +** +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + + +import QtQuick 2.5 + +Item { + /* + Slides can only be instantiated as a direct child of a Presentation {} as they rely on + several properties there. + */ + + id: slide + + property bool isSlide: true; + + property bool delayPoints: false; + property int _pointCounter: 0; + function _advance() { + if (!parent.allowDelay) + return false; + + _pointCounter = _pointCounter + 1; + if (_pointCounter < content.length) + return true; + _pointCounter = 0; + return false; + } + + property string title; + property variant content: [] + property string centeredText + property string writeInText; + property string notes; + + property real fontSize: parent.height * 0.05 + property real fontScale: 1 + + property real baseFontSize: fontSize * fontScale + property real titleFontSize: fontSize * 1.2 * fontScale + property real bulletSpacing: 1 + + property real contentWidth: width + + // Define the slide to be the "content area" + x: parent.width * 0.05 + y: parent.height * 0.2 + width: parent.width * 0.9 + height: parent.height * 0.7 + + property real masterWidth: parent.width + property real masterHeight: parent.height + + property color titleColor: parent.titleColor; + property color textColor: parent.textColor; + property string fontFamily: parent.fontFamily; + property int textFormat: Text.PlainText + + visible: false + + Text { + id: titleText + font.pixelSize: titleFontSize + text: title; + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.top + anchors.bottomMargin: parent.fontSize * 1.5 + font.bold: true; + font.family: slide.fontFamily + color: slide.titleColor + horizontalAlignment: Text.Center + z: 1 + } + + Text { + id: centeredId + width: parent.width + anchors.centerIn: parent + anchors.verticalCenterOffset: - parent.y / 3 + text: centeredText + horizontalAlignment: Text.Center + font.pixelSize: baseFontSize + font.family: slide.fontFamily + color: slide.textColor + wrapMode: Text.Wrap + } + + Text { + id: writeInTextId + property int length; + font.family: slide.fontFamily + font.pixelSize: baseFontSize + color: slide.textColor + + anchors.fill: parent; + wrapMode: Text.Wrap + + text: slide.writeInText.substring(0, length); + + NumberAnimation on length { + from: 0; + to: slide.writeInText.length; + duration: slide.writeInText.length * 30; + running: slide.visible && parent.visible && slide.writeInText.length > 0 + } + + visible: slide.writeInText != undefined; + } + + + Column { + id: contentId + anchors.fill: parent + + Repeater { + model: content.length + + Row { + id: row + + function decideIndentLevel(s) { return s.charAt(0) == " " ? 1 + decideIndentLevel(s.substring(1)) : 0 } + property int indentLevel: decideIndentLevel(content[index]) + property int nextIndentLevel: index < content.length - 1 ? decideIndentLevel(content[index+1]) : 0 + property real indentFactor: (10 - row.indentLevel * 2) / 10; + + height: text.height + (nextIndentLevel == 0 ? 1 : 0.3) * slide.baseFontSize * slide.bulletSpacing + x: slide.baseFontSize * indentLevel + visible: (!slide.parent.allowDelay || !delayPoints) || index <= _pointCounter + + Rectangle { + id: dot + anchors.baseline: text.baseline + anchors.baselineOffset: -text.font.pixelSize / 2 + width: text.font.pixelSize / 3 + height: text.font.pixelSize / 3 + color: slide.textColor + radius: width / 2 + opacity: text.text.length == 0 ? 0 : 1 + } + + Item { + id: space + width: dot.width * 1.5 + height: 1 + } + + Text { + id: text + width: slide.contentWidth - parent.x - dot.width - space.width + font.pixelSize: baseFontSize * row.indentFactor + text: content[index] + textFormat: slide.textFormat + wrapMode: Text.WordWrap + color: slide.textColor + horizontalAlignment: Text.AlignLeft + font.family: slide.fontFamily + } + } + } + } + +} diff --git a/calamares/src/qml/calamares-qt6/slideshow/SlideCounter.qml b/calamares/src/qml/calamares-qt6/slideshow/SlideCounter.qml new file mode 100644 index 0000000..d5b2de7 --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/SlideCounter.qml @@ -0,0 +1,29 @@ +/* === This file is part of Calamares - === + * + * SPDX-FileCopyrightText: 2018 Adriaan de Groot + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Calamares is Free Software: see the License-Identifier above. + * + */ + +/* This control just shows a (non-translated) count of the slides + * in the slideshow in the format "n / total". + */ + +import QtQuick 2.5; + +Rectangle { + id: slideCounter + anchors.right: parent.right + anchors.bottom: parent.bottom + width: 100 + height: 50 + + Text { + id: slideCounterText + anchors.centerIn: parent + //: slide counter, %1 of %2 (numeric) + text: qsTr("%L1 / %L2").arg(parent.parent.currentSlide + 1).arg(parent.parent.slides.length) + } +} diff --git a/calamares/src/qml/calamares-qt6/slideshow/qmldir b/calamares/src/qml/calamares-qt6/slideshow/qmldir new file mode 100644 index 0000000..7b964b8 --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/qmldir @@ -0,0 +1,10 @@ +module calamares.slideshow + +Presentation 1.0 Presentation.qml +Slide 1.0 Slide.qml + +NavButton 1.0 NavButton.qml +ForwardButton 1.0 ForwardButton.qml +BackButton 1.0 BackButton.qml + +SlideCounter 1.0 SlideCounter.qml diff --git a/calamares/src/qml/calamares-qt6/slideshow/qmldir.license b/calamares/src/qml/calamares-qt6/slideshow/qmldir.license new file mode 100644 index 0000000..d2da9cf --- /dev/null +++ b/calamares/src/qml/calamares-qt6/slideshow/qmldir.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: no +SPDX-License-Identifier: CC0-1.0

^IhAs89p<&oE zqZh4!W(+;;67oD#YcX=$QEmB1xEubrz%H1$@|zhSS&vt8%<`qfQDZC__>p?3&IG3^5jU^wA!O!m8i8;q!k{e zYRKN0A^j@&<djZ_?L~Qx&RCWx(d}stRl-}_nUhMY+K6fTo&gsRq zf9(&AUQVFxwwVX|)D2MEEU3o>8pfb=f6#S7`Rw+*-e$@K`x!lvU~H{@IBjy7*|?ou zj4~^CEGcoO?#*$FK6bwF@wczGa@G;RTG>qy2=-Ld+BrDw>uoa8>`Pa1H%2Qa$s50| z#pgEHP0YnhHLP1pmLN!2QqU9)<6JMQQ-j{AWw4&aPf7*u*jpz_@Cu6p zaw5_G`bFTqKx zJfjz~iv%)x#UW|xCdyI|O0Sw;hgwaAkx-U_UuRHVY<<|kE;g7xjLn#NCKhGfpj(0k@<^7*}YsZ3~7 z!)N2w7|wnlzefv2c_QqQLX=GR{H$oHMSX8Kmef8hYA-&D1IL16Q>y3h%UC>ww5G7> zsat&|xGfb`yhf~C(out1oe)>4iV?}YI%4;MQgkXWB+0p={|J?jyFvcH`peXPl?aFrVL$n z-ro$u6nEBV)mFA#E@{QkzFev25bo^nvAD4=M z=!V7n9s6r=o|MZsm=$yK7V*z$b$JKJ<_|lDYTJw*CX~B2-8;opDBQZ-ab@_8{cz6i z9)B|sjt`d(-4oR4an&=K;r%AJvHS$F=CL`Ks@QqKfu~c^QKH=X&Il{UTWx7fZ|4d*9XZGg z7)49E-%-zPT-kcw)_k7rqDG3rV!p55eAGFS4_t ziXK;e3}EYf<%nnHpBsYNolNflmB!TN=Fvc*p0v?KCBd~UsuW!CyNlsbo1-euhUZ1& z%b}n9$!~RN_8|e)GaurB{g+fE5vW&z6F3G>n~xRh9~;D@3a3R>V+4_(?IqjH>JR*M zyv%HaoIgx&?Ba(AzMSWtD1>>jvaEoL#Ibtn0y<%E^w#g3cN#nYp${d#3gK4*Jdj>|`}T8Hs3apt_pj8t-CAfF_G3=N`#lumv6rAu|r}(1=_WidCD?3sX-^!sK;E?Ok z+FJF)knWO9v(|%xhi&8RmvtzGz*6mAs*$Td|6!X%@|6gRN@i4)m^uB)99f0mN)R}K zve8$=pYC0;!f!Z;2LYk0@}8Q|awZT(awE=6o8S}f31?+AtMOMOxL1>ti<;^&TW()g pErBijsR>HwKc(dV?&HgE{LH%p5?aME9>7IF7c6bgmBHNp{U0|OhSdN7 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_12.75.png b/calamares/src/modules/locale/images/timezone_12.75.png new file mode 100644 index 0000000000000000000000000000000000000000..921d590333c01e3369c97ef98ba4c62586b84dbf GIT binary patch literal 867 zcmeAS@N?(olHy`uVBq!ia0y~yVCG?9U<~1428!%^?EMQ!v7|ftIx;Y9?C1WI$O_~$ z76-XIF|0c$^AgBWNcITwWnidMV_;}#VPF8MZ+OALP-?)y@G60U!Dj-fv9++ zTgEbnl=+Tzd;MCD2DE}v?Ova})y^4p}prr|NsAAcdzq1&_OySL4LtN8U|Rz zRFuV8g{zJpI#MSov2dBff+b>;UcY`lU6xJT&DBkZQ};=^Bv3J9lDE5yUdUyEb|8nd zz$3Dlfq`ui2s66gH3Tvk(>+}rLn>~)z1%L??tgchF!jbvA76-N2?7y~i@AEuFkl$R*z_{>jgxtzk_saCsWnfS(p1KW1LvDUbW?CgggMp>4kzt5| zft9JTm4UIgfe}zc!=rM2paw~h4Z-gnp|vd$@?2>|@m+A#nC literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_13.0.png b/calamares/src/modules/locale/images/timezone_13.0.png new file mode 100644 index 0000000000000000000000000000000000000000..87e0e4353e2492731c09e267b959abe3a88d771a GIT binary patch literal 911 zcmeAS@N?(olHy`uVBq!ia0y~yVCG?9U<~1428!%^?EMQ!v7|ftIx;Y9?C1WI$O_~$ z76-XIF|0c$^AgBWNcITwWnidMV_;}#VPF8MZ+OALP-?)y@G60U!D6Qff1%uoP1k9`~Og#CA_Z_rl6r2&vBrq+AY4R^12w>t2=xXoMVPs-vW@fZz z4ow3pW=!&ScQH1Q zR~M|k@8a<&`G2?ZkMlQ8!P7 z7hZn*qL3s%|NnEJs+aXI9gnab&X&64mTYU#EO~$@fz5z<2ICoqGzQ}aD0fd;+sTq? zGxeuw+jiWj+I*Mw_xol!mozopr0Q>CWy8r+H literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_2.0.png b/calamares/src/modules/locale/images/timezone_2.0.png new file mode 100644 index 0000000000000000000000000000000000000000..1bae9510ee8a4f34528311f7ff7172165b7a0891 GIT binary patch literal 18803 zcmcF~byQT*7w%O=L<9t+Q@T?+mG178?(P&2>FySgE~%jzq(eZOp^@%pXx@y!-+Jq< z_1-^kt@m`fE+hAxd+t8_?EUR;e-o~xAc^{#;57gMsM1nmDgc1+1^}L^y?P1zpK!s} z0oaEqD^XD;Nojc{GGjL@TURD4domXl$xi@48}An{DAg^3_iY7~5>d}H_p#@_sM~mu z8ir)$Ck*`B6(mfi5Tg*mtn$}GI!`>x zg9v)E4DDF-L%PmXFUk9nw9>I~V}hLZeG;LLm0qtD zkEL9S{C9FMuV4JtE+WJ^eR+COROqry6H{CJ<83ZhETQYizmiqM%wQ*G-h=IQWs1yn znix!!Lgtj#v}Dv|&j_BP5`xV;7iNa6MxOF%KAV+9Fst4ljg)2u8$3EjV`TCd10@AP z^o%`Lvr=sFdFMYu0|rhbHxdTbpHcKbw^n-3>whAAy`)>mlkS+OniwA_5;$Qx0;r#%4ZKUM`s6fD_b)%S1(61GBZyra{$l^sZ6y>p`=6m z`s5P9`~3OWRUTs>2Yc53T=duhox0+(@_xbY_f8t=rqt+n!cRhLhI(|{m=QYV?K1=F z-n)aP_17)8=Sf;Q+xr=8U|p~cy3X>nm{)6Ci}<-FokFWW@k!0MsN)4uAF%YH;CTPg zG4i=Sud?=veZtj+b?RA8$+2ePmHCRj+YhMpGI4HYTfnW(nZlJG^y$P-l&zvZDa2WZ zUp8i}uKjjncI;+%Us`zZvUFA?J~w>BEbcf4s_d_Kh_w#3S(DtKJ@?V& zuW;p`?V^m2ML(roXNH`o5*HpD+D2YYIxT};H25vR3fqOo;{=8z*xjLSM|gZ_ayhx5 zlNYY(+wfaS@i=lEZKF=Gk^7#C+qvtF zv(PlKMAHt4jS_PEy}5S+uRVF|8*#kaGui5UCKxmOPIR-}VH0UvPPouFB<_XoF)}NL z4MS%9o98t$8l{*_R9L@aP`_kl7wb&`&y?jO<%<$rV`2y;L(2_fUCy$C z$t71aJfq)u2)erpuFm!y8dr_YcE3GkDQQ=J*nK1{86<4DAalEe;7(bhnv<#y6k@ff z1!~bh|Lcy?nK#+X$VEq`zrk!?Q$XxUm^l?GujPz^&{a#d8_zQ5Cw|oEh%)bw(s;qJ z(G_y^6sQNZG<}I*BScyAv`t4 z)#B?P#Xyxj=?LS-t*P_fVej@w%@$?s%kMtxDxS<)I5Q~stJ7_XqKJa%@TXJ ze3fRQ)Bo@jqna@FOY5GNF9>aHE-Or4t?nh;FR^MZ%Wb<{Zf#kF4<>}z^scdF12p`( zG4lN~Z0|8pO~6?cL>i8s3YFz@Eg zWTz1zLDM!e+KLg|9)yeMp z6F*cWy6MV?Yt9b~O}5f0*XZIQ{uZB@IYJ;$f7=&0qSTKyt0Q(rJv4_{?$MMO^<#)6 z^Oar$qf4534)F#fpzJ`XS+RV(xt;iVrR)Z?5=#r@T-KzYuc$d1W#^mQvNSaGu%XiJ ze^4=P<1=l)caLOtogLHig*rz?vX4a7MXxGMXi9w#yK+$@t+(u0=dQ~IId{Y5Rh0Y@ z@CxDir(_L`W8$H!dQ1YuU6w3p6uuX$MdmBXfONsk!wsYqM$W*5+cjugPbg$rDCquo+QL~IysKNqI`CjO`=CV zuQUAYKW=mn5?-8<9FQ1(N{}*kVkqK*&@mh9^0<`0iUYM;+$(Mcnta}XV2_Z$3{DR$stEcg#al8X~NoPdaRV5MzGOqEhtM;YH=mvwFmCxI^N@B!waepXR2<)3suuTjcE%H9Hp_#JN@ zC4W73Nn_}O7;^=WQ$h0a--cFuw~|u3WW86XDqdYneK?><{2?XzD^OR>4*2A$e}#~A zh2wz1{elGs3M;K(utG~-j?cuwj`_2xgRvR2r=2743{Y2-VB!J(_uoi`AEpjFFL1s4 z2K9f9R%ca=@chLw5gY!jJLZU1L;qR*QV>8}vsFoEs^Z_kd@JP@^5r|DYIwQp_;1(u zR-&u-+x$&g2yt~Dexf@FsS-n)Udd^>kGBgSoKw=kUMTgJ%-4KR*Ob?1yQx067a|nq z9X>u>`|BOHGR)!Qsp8Xpe#V#@#R3{*qTC-)nc~sIZ|?riG?gH7>kT)&ZQ47RwCxb& zFb^_2UD~`O{?vf!!eoXeI_H)>&8xwr@h2w#75N*smom@h?Wm;D%dSSg;NhpX6_Puc z8ffijcvt-;QtiK>aJflMYr`{ES>(XUc)Ei;bKaaqHK zEt5{_Pa4#ErWeC!odwC7v1d(^K43eZwspI9j3h(BGntNb@(kli1?0>Wbbaqd#NqD* zNn3J$Lcehvk%pUwh9!L7?3E^rw^u>JKH1=fw5=8rNR#g`Zgw(q{Z)2fXcxS$*NZNZR$kql832I5`w9_V=5hlff z+Y@967{$*V^+UfT86k-Yfdm)O7XQ4*>WLmuw@x(XD|^=a?lnd(#V3wc9r4pzpgt;rDSXjC{h1(!9_Hyy`v{|o)UBfp}uff!WH<9K(pLaq7ae4i} zD1S8+13QnGRS>6f)gCD6;zh48kSjwHQ%!q6t5)V%U>UwrFl9R0Z;|>P`1ws$3r-Y- zd3b$4BO#BVK8?eUNOserHmCrH1S>#Xr)t0I)*}mdH^-UTR5LP^&?)ipSE<0LmwX$| zQmJTJvN8Wt-;a(zBYk4mCAZc!*-gwhxh6KYI5%^wu9tmZW!m|fyf%Idu#R(^JE##hOsjH3n4<` z9mcy;rKQSdf(^x2{jbtb?#5`GgMk|iB<&uR)tAcOhtNyAkVS)%-usT8J~;|;@GMo~ z-+PN%bVKmQLL;>8Gl~Y9c@}rJiEfaVdIB^}SDbT)B#t9nAZ6X=Ob#u3me-nJDc1Ok z+pJ^za%B3<()GS{x4-tlEpI7ad(2-h?Dcre4$Asp{@zyyWsO|r-nOZ*cxT_nJ-snQ zvDFC1UWWnz86Yk8QOz^!V96s(Pt)(|X^gi~ZY3Q;U8&K!m%yZ*63x&QOxM@f_g3fC zi&uds6weZT!CyBd{lgOtuQxN}e369{;zV9i^}KyvwfdZSm07!%vD{|55z6j&)V_V! zKV&qlBRij+p6qToh7`Fmo$VsT5pb{s6_iH?0M4IhEs9mLOyJvEtrkIMYF5reJsUdn z&_lg|evsYM!)~bcJU5WFd0~qSi)g^=Z*e2|m6hr_0_^gI4g(MVQ7*g_ele1H0k25` zRdQDNB@Hw18SK)T^a@t50zi?N5&ZIhH}n9)3>y-lWlVgxU@KT%tKg3eteU|l{E|Vt z9r@9}ViM&(0hiXqdMQ9DEl%ZgXSEv`a0IE=>BE-usC7TTQififpJHmg=%gpcPEH*A z@Dxn0a{luS4DIL;k&n|4)hHWL+ESURp)Cs|*w#?~+u`I+~9oTJn)Bw-`_pGU}Y#p!VAQ3%$1VdDZk)(9h zd=~3Ai%RdSD#9;}JQl7%W|BBicZWF$$amd_AOmUk%~jDAE&niu6SiYp94j1WGz=}QZA8~k5{ksJZ2Sy^Tqj2h^L~aC7MSf0t;vdh#$uDWb7v1|WZGEiYg%eJLyhUm=@;1eqlF=A5`1TPI=y}7J+zoRZkNzcna6!Tk% zX>zCh_16+8c@8_R->H*8aG+MMoZl zwQ3O6jp3OkVHQm7qQCH@qI+SGr;E4_rT%N}!#)pwsNsKXmKDoF++Jxi&+livlIh)& z64mE<$0Rn~+iO-xJUjuKJ5AEUWKRz809b|J!p?}g(`9J2y>1ivJwJ*@(=pRl&`7Yp zK`bV5)=YYy#E&=*69oNVem87x3t<2Iqosa*<)ekG^~1}`)M)ctC}UV-<$<*hJ^MK4 zcR$^F!kwSU{V5WAI4m;?l}B;XwJTo8th&O2C^@B6N%kBYG_owe-+PAHtP(Z$u( z!Z8cQy>d>IG0n91zS8CEarL%mx&&n6a~eMW%3b7UUE-Usz8BM3sO>|Ot69f2^s~P` zeg$li!Yy64e4k#flVWGt_P$-D3PkLdLpW0|ih(_=qgF%4TPVg~^)<Z&|q7@>96e- zQr3ElF|Ko*c~l~Go&kvpCY$&Gz+o{|gomffTHNFiuR^{SHr2cT zd!KmtgGM7C(0TfAUhiu|(q$F;l6C{_Hr3{3Jht?3qvO28<`wXHHOaEp;Vl5LNW#5% zQKVQvn>l@172@5EciIZQi}gQOg)XT+3;{qt1uR2Yk0K+*s2C5@$;nR0RD{SL?k_)t z5LT7=n*m?e|AjRRItFG580n)GF7sn50_0+n+j{DvSyZk^0Ic%Ba*fEyB}vA$hCAc6 zG@C=8JlQY!JGyp%YNjWW)l-dxEd|^KDud;Ug@^M8NFQRJq;ui5y`A=;qZ-qMPF86Z zw}hOz95L|@<6_Nz`~#A;HUse612%DI#oU9d!~2GOv-)EZf5+3ZKU4}*v*4l4UrMo% ztK;0J?J*m5QosmPH30zX%s<;)q)_eWH(JY5DLR)r>m~a_R~CPFSpX$$J|GkT_^n8K zv#o2dU*Gkp^r33Z%nRVvzsXj)pH8=+jnz(%^;HHA zj(SdF3jXB5X0o&?_4D&AI_WF!D>+P@v{(RH6x@b8TVwiupNDx%(V2DjIv6sPB9k!9 z6xJADVqlnv)pObNl18aD$^f0RY+V3Q5{`cVz~o?~i~CeAE_;|s!KPk4Rho5v&u(rz zN96kH_Ec#-A)X==YEh;_3(K<#&`?eQDBjF&)PLHdvRO~1Cb-BXXWMt~$G%gO#E!9u zl6ue3onCYH(3+ERZRioOmS>k-i32za6j43{%C`1m4caUG^ba0jA;Qq((~yewS?c4i ziVS5!oHxzJ{d^F%TRZt#C^iFZ$S3%IPE+dUN7tlHA4w!palCrXf_;to?(Em&X_pZ>bzsG)0bx=XAbd6^~^*0RIbn4j7 zds5Ao)CXxYzfJj6N@+vl{hz?@LY(dku}`;o2+8GdVRORWx{QDrghst*#)FyIO;&Sy*xwEeM^?x$zNntGtW`CN4Y>ubtm$d_Re#Fv5$6^zob$kBj9CDC*4 zHh6vzu1EcWmFpcp>!KmJ4r)1^64yGBBZvT48{bTm>aH|x)iPj4mwKBJfz=#`)^@}w-{HqnCU##cl;APr6hLb7QUYy@ zgmklZQf;A+xNV(#_(Es40hDURb-$Vje2?sL1QA3~|2Z%+jWr#Uvhb^kkRn9gR7Qgt zr_cKAty;Q)<#sG(4RasA%WT$WBMX#It!OU?I1UFQk=&mpE?Bg>{}-E-$+8&cv|Zm; zHSU}w;^Ib>9t<@|R!MQ2Ytl2x&|#$OO_0fmbrns;jSc)feI0PVilumzzBKd<$hvp~ zl(g&%x0!xsWID`~5<0v~(WIY;s0G%VjB$Ksn?3T-6d|bw*960XkRfP_!<}m?pa>~YSWTn_x=4$ z++9WS#M;lUo+c}g*?aMiIWDb~uq3h7o}@?ZO);`o-#U%KlPZ&p@YchFM#hFJiGX?(+9o* zubfngwO^)N>QO~uD9gBhFwpwb=D6C{;tE=-jq=H#r884o-94+7O?|izL4rn4x7Y)n z)jxOOqt|}e|0GWK--RY&7BZPIa9#>Z(G_MaRM^xPEH|tb&4M(*DnO@NTEYvUBtu1E z^S)_AoYJ+Wx`EM2BMbM@^e$toeEDL58+t_&hlGim=L%~{&G0{+^=FnVF$1eF{xh(5 zyOBkjYfe*a-}S)?tQG#XfF3(W$EJ&IKmQxzjJg}@UZs5HZNsjaET8*R+q%*w!%KiD z79KNt{r zSTJ#k02tV}jCsL8eQ6=CUYxZ~^6Lc*JFlD0m5bo*h~wKXbqB4dmOoE~m6J9_(}c)r0`&bfV#-nPykUVmvnob%{6@WR*`t4KKW zl63icB|G3`hnxFSqgD#&q#XSW%ji1IPbJ=MNvZyvBTa8#{6&Qhj~ah;+mM)EjUUbP zHIsb-w1tU}!P0HZB}yaLwb>ZPVNx;wAG_zL5-)>jz_*#g_gW#t2mnzi{Mn_Ri*0sm z?04&VKXpuh4^|6f6!uE9{MiPds%lp2)OLKf6EN#tiknp%kcDCy^51rD<=T;y+fKvE zK{z;Q*h~iT=bikVn9Ri~Z_pY{s%XD2x~|h5DO8h1N44cVn2_2HdIQZC$IRe@q8i>S5YT^&pr2aIZ{GE`0}(b@9Ry z0|7%+)Vne8=ffc>IejC3dX+&C3==MteeOoJVbOU?QQu{P9zb*kubvqR!OLFjs<%Dg zG;O8YnBPs?O)k;xiY4I?M@aeU2lELNsSoF)q>>zY=9>g;dcz9DP#1VDc$vep*wW%v z3SHjff>NxN7^n<(o~Udlq~j5uG~VVw@*5f?>8d&TKYNpcEp{Zw{Eowq_AXunF6i*1 z%yIm%pv$aVJAYd+_51;+zw*HKN9l`>Fio5>=bkGWbIR1yT(sxy4#r~8Hy+zL{IzDb z>+4T9*HTQ@&%V@Q{F@@cb(YCcn#ED*=xdVTr1)5|c=$lgV-&`zUwmg`+i~XOnnA#@ zQttg>V*oU!*jft%$l{#Sz=oAU+z*5Mh7>xL7?l+n-w@*szae5^6pGpEit%D1KX2%9 z|G_V8Q04RyaJ(Xe8|P!TkJX;&pu|4Gf)Iy9V>HI8Alps)lN$x+s!H?S(#3paXM z>&1-hzO4AMW3v(3i;n9${n)TDT8v>F>~QVzBJt=0f>(g5(O1~pyAdAG@zn2SWZoGg z4~DMS=|y1-T8#YZ345y7yoxpLZ|~1}+$spQXr>2vOyF*#4&7aFcsQJTzVA}=)p}NA za};3j3*(FS(~iav=4Nge6+GL_LD)RD_pguRJ*mr$Rxcv~(jxz11n(8Y6<4!-13#V| zwP6Ogt)OEDmXjY@V`aVqRtYq(-^+x*2*g{N3?cioX*)wga>zYq{*41Y_RS07_B)VI zE4?Zz@Y@a!bJX=Ns`e8r%MYkzXIjC;vO1}1^o;M$X0#k;yDZMI6h@uCWe|JZcS$XZ zi6y=0I1>-Y!B8E5P(Z?a@_uz7`qW~T#^>c+pLDL>o9H?`{Z?d9MFxJO!>8`mzv!?b zaJX(vp;&uuk|iZ}j6AYAVN3HCAc9LAEtu1o=0%t)-aFAs#NfS7yi zxEB~DZhG@MoLrxVXe3{9AB=5ha13dbWf8gtIZG;~d%&_fQY#GC(c7ON#-b6JpoUUV zV@F6CZ+-q46u8`S=+mfw{ou!M@RWqf|2C19RM2s0#%|2ueDzw%RB6GV78Aud&+=_U z{dli2c_Pb?^Avu}X?DC*SwyB}U7=^d`AZ7;)?Y&vmtF2c!QQf~KEEvX2`+3Hbq(CV z%$KrD$i1ay;UGEYojRQ-mRS^`d>b!ft4l|rpmenuWOifE^S8Jj2RZa%iqE5tpRJ|5 z-zj)tZE3BTOGxK79SanzaqIWIQ?I`O2Zl3k+dal&&KQH<}yV6N7Ei-C~ ztmVh$Z67+l*YM{0hj*(!A$@9ZOFG|*_>Tot-UInpQ&QTExT%lHc@Fu z-6%QBb%%XO)Do%a5#uy?1xn;Hky6Sw`oBGh4IY12RXX^Yx;mO!l+ML5;SkhGPzlsj zglQ=hIc)nOF~8Xu@>rtkeCfdXj}LQ( z(5@cO{qLA}KdQ(3Xot@lX1*x_05+V%o9c;?*Kg%0_=RF1{n|Ofa5ox*V=8sA!)L$2 zW4pGpprQ0F;7EP|PvGH^)w*XhYIPlK@8Smr-DS@N)c1I6H=0l5d%KNoDXQ6NF!^^Ck0IkZvh*CU zJ!HUT6l!B87_R^NsaJxc??oTIG+B|-E7X`bN6Vb-f2LeEI?a3##&A!UG>I2HCrR5% znHtF2*xxzp(a;Z$SB+Z)xGsny(ceh}osf_`9hm?5t;%xi{+6KEZjA`NpiV&FFOPaS&W_?%wCZ-_Q)hl0`y9Lh z(u>~Na%Rgm(C0k=^7V}5{2mP%@pE3t#B}__S!2L)$DR4Q3&IzUe?%82=~Exmu{0+? zI9Y1wwViZ9n6s^B7bHrMXCikwc?^P;9v-rk_w$&GiKmS@V}RL-NO)p)^1?O%I)odA zJA%ZHV`IzbXt3T@GhNg2T|o>V16vD(+sHUx&82cmZW8sPgV`&`RBknzfCAsH^eZMd zvK;Gsi6oF~-FgY%LjqQWuc7Hr-JH>=L8QlB1mT^MWg0}lf*I}~^#1F|4sB%*S$_M$ZsxEXEs{R(tLVj5=u_%>`EjZ!1){C7jRX!V!h{BmOwCjLQuHrD!Wz?;jh#>L0<);nv6Fp!0;)eVPvvcjHL zqAl;erH9Y{NG3+lsL#~UTXnr~n|Vg(B039me@w`z|2bc`Y<(gqUd5 zfy4++DEU7}`)p=msxv3QcH=Fq1%(mQ+7YhbJ5=E!(Hwd|qSZ}hk3vnr3 z4?lGr#y`~QqX1I`@c4nP;nz+Y-Wo&bjFTav9nzNZ({BA+!KP_hhld%@1ny`I(+>2( z_1ASic};M%Tj?lB>VqMvqBp8eE-#dJIAw0Y==2eWqgFT;8UT^XY&Jy)0C16YQ7tKL zqyX2&q2~MniA5=Iu00L}9*F(Wq}xR7sD3`kA4sP)k2z?*@NKAY>(Q5aoX~S9^YB(& zp4nT&`b_T?)68E8GcsV_jemae5@2ooV5g-o!{R}l(LuQ1QA~8w5Iaani~GA z3|M$RyF;@~0mcgtcemv${6H4(930>G*5vkEQzawqJ*=>9J?qpoauCneF3J1kt6cb?DWe z<^4>pJ)F;Xy%BSet zGj7y|$oL7tx*>4rRW`(s1VVGw%_SzSuhMkCh|nvt`k@z-!ZQsJlt`<_!L=!mJD$0Z0)wUA^)iqiQ-gEq1$Gk(h(yGoP4+Sh*JGAPWs! zO6F~5ixVC7^}=A=5k$fOrex;&>;o~&Lb2P}7sC7}pH811e{CNxX>GB$y1`x8_L5Ex%(~nHe_TF>8pfn1 zH3c4v2Q;fD;9wvhtYXWz@HUpZ4G*P&C~Wk(CqHlz?eqQIo}iJZQ=Xt#%Gpj4SQ(;6 zPM|?S?F;kVRO!zYsat*db6Nwu06o@;~=qhMDt(tYVD-om0JOuCI;jvUBk54Zok;F1Rt z;PS7ga7jeT5kG^7n>7FatBiqBDW1fxqrk}+7N!}KjCRy)KOB#ce%Py*|Gj}wE2TPE z1fNC#Q=XGlpCsJpvKr+7i$Ru-gWv$4@S-D?^z6~+n*%DaMf49940&y1ON-bPh~4Ac zb>{V;wi)vs^T>qDy^I$T%g8|EV$quO3SUn<34b1}$(4Tkxz4X?tW}U(t>bAS;5xCL zqZHN}qleA3nksbLK*-FJ^f!3@p;vE8dFI5j<9ZdBG;92$8Lscn{M7vUUSexbmL;ZQ zX{Tj@qRG3pjpyS!Ndusy&+Vszhl{F2dX>MUuGn$ta-?vMGXCH7gI~sQ_ z5hI^{;e}VaZ!zMhXF(sh8pXk%eL0Sp+`St#;@u6=tHEb>ET;WSBTIK2cgG!vfibFf z$K2pEklQ7bbFqN2Cvq=q1*68%p zVmxPj4G|&4m(g&D+x$5egW^%hhso9N;t*{b?Ui=g9NK!k0!80+80jG7aB~yhKq!tj z`coB1n}k(QtYdyVxekv~cl39v<6j$=-KjlT{dQHo?C7K^&EiQLap)Grn0;2vpu#S9 z9Db?|jaMVpm9f-WnF*+SGQ&bMDd}0P8%=+?SO}WhI;^gJ!EF{@Ve}rPR$f*6eBTk-!lS}S%uWGlbMF1T$>qpi#Go5c zJ$g>bzesp*+%y)@H;mgVd-L~PL;viLhmceM@C>i-RZioRQ_J8@(7M8S9Hz1{{$hknx z2s0Xq>5i55heN2@%l6}dbLi31!>zFI17=}`nM`}*F z@ID!oJ;FMhpw&1-`Z7Ip@_B9guac^$1?3cM)gKv5A~T4Zr6^k#QmXcv`@+uZHRj!e zeya4T>}@{KV}#RGPFFHah4QiAJdl4f0=}ris){dOd)#2x^J!GOJ(E&b#}1VtzUhw^ zg<^D6F=3e8jCtQeoV9CzHM33{t(+hUx9zSe_2`o*?h)E)CS`4Snwdn=R#Gb}_(xcwCvY8@UWbVPB@KpJE%PN9}w7eM0P2^l{_clO$^3Ao#7j~)9i3#56#@#6X1w*P%q=w9ZgwcW@l!VfZ5o*1pLR zUCVVpI(T}T(bGFNt^IhjklPVZk{<$dXSl_^EF;_YjCQ?-Yc*RwCkwR} z)6&)03KNAZ^z=*S>xcN7Na)H$X})o@e|+qNRG|T*Tttg6f%8Rf|Lx`N*oVt_^!skz z(9vd|+u4-L2^ncA-F(9qS_oczC$6GcPi-#_Rh`$-LK36wfhGB@isnkQqpPQWby#ta zbMyAI&JXawb6sc$Jzkh`J~|8T&2Q&E*^I+T)RbXTlc`WKkT}y0m{iL3^I*oZZD7J; zmzcJaCGsnhcT^{*{^M^>J_GS_J^W}F`Fe=A++{LANN}O>*?-LHnU3q<;sGt^2OSDxe$Mn z{*I^~Ela8C?W8+(Dt>k5N!?!$`$uM2EKr!#7LbIonhe7%Ewo-n=UX*cFm6={no?Lk z6JkgxzsD`USHUg5K9PT!?i|3T@>|!WUB6QCc&_QFG!%TT+c@Gb4g+$ZVZ@}P3WcCx zZ|P&@`f1E~w8z0RNtNM66X67y)^z#{?X)GoN^w#xy&LPo1+CK}*}=E7K|P6=AmNia z{W@(_AS-$_jz$Fd(!Akk=sU@~+qW}v8F}E*x3@twtl?J2t55fea)&Q&@f!X3@z%zo z`Zd7A|F1pPbclXLy6C!nL}Sb}0`xzpV>vs)1=)e}{3>?`W z4aFLGj1#WpKBj6-w3OgH&{3U*;y!-yApzvN@#{^v0HY42j?u^j=gM0Y-@&Oe79hfvVp$7OgM;tDSPmU`yZ_f(S3T!Pu0coWtj^(mX@thgMvP7W?Ke7g(RR{}qfK z*cvi`gq7+C$n3_?U%mufP~df{YVK{dM?+&ZO7)aX8MtsS(xbW4Q17w>Fp4pTx82NP zl*01ksAG?>`vdl1vc1%v^^~0f{-k~Z?3U4g2>tI!u9wSjm)ULft^`cjky29nD~YmD zEGk~vgt7Rx{aNQ?PZA}JYhu*G=-^9Hai~z&@{Ct8MskOg>&L>1(0|evkIm4PpKWgG z{*oW^7Hsfw#Kh#tyvz=C1eoo^*ZIqylKxoU8b$o+4aJdF7K@lN)*^<1g)7V%4SUk2PXE-Q5&2TKimWz+To zr7Vu!^ah78VjsqlKgMiFTSyd%`3 z$JiC{_SGH~WRZ(P6m`$WR`;Z%Y_z!g4}?#T)fXP>T$|L*l$}j(BbNZ|K!{aBDp`AP{>xgXFi7^@I_K^lUx3rcULsJZ-FUxT zNxU~|ga}MH__MKTENh0d!;~;uaIU8mL6L~ozRe`SkQVdtMyXnWs1l{&tFd>pnf%jx zjb@{lXh#EngDm?FzEJcdZ&1SJ^G;v5Jm7bPsICu+4~ejAz1c%Thv$%W9r2Q}_FuVT zreT#+(?p+;Fday}?;z}!W4sdAc7W9oUWV0KZ+8>(!nWj)U0lHR)@L>LtIK;Z$9sA6NNvlWeGgDkjn5uXrY7I--@;(#oERjN>ka3 z)<5-{;YWsdXtyx&(awl^iw2P90N)mlPfr~bAtBpb_Vn1FZah`0>xi+_75L{d3zKDv z2-i(uVW4}>4qz3<7Gg&Nz9hS*@ZF~pLk0UYU}P@!e>5=Y4%Zum1f%T&&MrKd z9|+b#;t8W}^C_n1`C7D?iS-jzhhC$_NwmsCJd?;MAIUxnVY#N0@mMBZ=lx_E4!T;KY>K`p9j(u(Fp zF7Hm{CY1bFo`_nziuGd6@WOopOS@kltpBxjWQWa63~X`!t3_kl98G)BnFqdhVb&e& zW;c?N|GlBUD}*jz5|Wb-ULNk&;KoF$Rl)Q(r~il{4q0Zc_?Y4>&b*R(Ydc#L>SA1c z-m7idj*aVTlUYx*!FqeGwz@*MUw>lmJl^5}--rGQZ_`MZp9&lGYfb&G&HFjoFzBpg zBSBLess5X(e2=p7{=)Axg= zx6{MK4F5V5<{jsytw;C=SN?hf>5IEW!{%Z^@j{o2vEb{Ct37ojfCmF^&7u}4uE)Fc zk1JO4wCtE099t(fPZ?J!6|krF{|l*1U{g0k0V^{xwYv2(iAu#?15;;w4#(3<8%_PQ zD^5*^78B@LxdAO8A_jMM0x1y9)@_^UcppEr9WHjB z-!rWvUhlRy8M*iq7Prmo4R3=J4mIoG8i)S~7axkN91LB&mbO*pZ`)IFD(h>LF`39u zC+&vQ2APuO$TkD3{g9LiN(Zx05A!6UA1uVTAg(h%qAy1!n$NCNM z2usC6VCUBap49&OL-`&2EW876T`AwAnt$77V^A*Al6rIG>wU4J7WeR|-?-FR$FBYy zP(XwSc&9b3`ce03;{_axqQH+yVW3{yfHuE#6px4gzKD0P03I_r>F0o&UMXctSQ7nI zR+VgYeW)tx;Ohq1PNKpaE#iLH4BYM1m>gi77-{Ri0u*v`;~uBBl0ssdj=ydr$(9&^ z;~q~FDohDc-Mrc6VvPI{qa?;j$LX~*FE_6$leC^x0MR@64q`s1sF~WaoV~Iw+(QJ~ z_~34mpR*5{Zg^ZwR@|=h4|wko66v*69yJGIa4IaCEwam0D&wuw%KAxi*czln`qiUuE_us2 zGYHwbB+Gh`{QnLvGe2){Wp5=}=60X1&h-hw0x>r-rkfk5Wo5v=mqj-WdcAhx9k3KF z+kYhtVo)9QhH22?%`&y6WU&A8w7r)8XI>72vG3hA#t%V-zrKZ3_R4a#UAE~>mQfY0 z52B$tBYdG>BZ3)k+G;q`dbq}{D71-gJY21mGd^Lavymm->^J8nN>}TQ;bjaBQfttm zzRwx4qOd6`k9?zY*vtK(;79ZU$Ci41G=6zX>Mkmt)W3wIZ# zw{LAx*7m>JIX@*%?_feGKAYDCJKA*b%`ypD>?T)QKbc5sVf|0((o3I64X*V|{wnWZ zc=+^N>TK~7)lS`Ir7C`S6!~u7KgFx?CD~@5sqJJw9c+oa?JAMClZDBwI=t$?VA@on z=M=)c4+*2~y^(?89buHD;6lh)% zuw(*QCE-Vw^K^AZu;s27YFikOLD0@cst zeY6-<@jsmR2!PAKMw{F5Q)naj{gP<_8NsZ)M9=Kdhq)@o zQdi-WO-aH%$%Xzt0<;JAcX+(J_EjI>dKTtiCs!c>TmPipdn(bR5q;v$qHUue+D$`$ zygrP-V3=oj=h2XXLihY`DmaM3Z!U|^_!eg3Eww)V8yJM2-GmHuma4GhV;bC-$&kvZ zF)G_vIS^>25Azfhx7d|$N=;c-8(WA0oqF&jp_a`yG%#rKQvK_p=V^#t0d5h&>%;zP zbsm_|bTRI0?=?fJfzQXXTWnbQ8$u@y1McE<%AiVxhsPo@UqzV|jqFg8B|e?r31GG2 zUmICQz3BZ&7$&m7dhFF5lxTikSYTlj=u8iT4=!Vf@mcC{c&TU>p(I~2SnWKX^wMVp zA^73f%EO7`(URZ9EUdCO{r5$S=Hq748CMpXTL{t#mMN{raaF9%>1=$v;xsPCspw_{qta$2wqDFR=OylAX3}Ot^XmD{RnW z@iFHpBopnh!*uf6(s$7ry#|5=WKqMzs&zd(m~ZxmiUF|H@icLkr>;-pEeaw|i854UzTS{13T+gGcU0=@vvra-~dM7{$cVaMgQ{_ZpzGpS)TVU>4#x<*7r88W_N2XQv>X? zf-2jY;`k%Yq5!KMyo*E6JdW4F40-oB={e+`csTXAmZU+oJ1PXB*t0zduSmAES?o=0vj^fuI1aSg_>Qxo9Hh zYuM&2r1s%krTQ}?YAu*DG>tGR43A>7mU)XhKl@=RveDGVu3HBQc6-`0*o&=JY~Ph- z;iJ3BM{&7EypKpAO*6mD|k`%x6`{pR$dhS?a~xg z%E|DV3s@Hz=*s{3)O)@D&Ha1-+SPxks|$!um2zQAKJvuq-Gd)DHXQ_Y20sgT&tqg* z{#N;a$>&*Dn0}uR;=T0e;QBqO8xj-WL_WRw^x?u7qgfZWyF5Bqz1r6Kgal{N%z*b7 znYHI%&^l9ijv+u56quQ(zq2}bR{bwDfAr^kv0h*O){p?@I?EGBXCKb9Q2LtZk#zNq zJ;xOLEAwj>CLjOR&R%6MllT$X`rrpOaF@URcKe+yPfe+NT8J!XYluahy6#D~$iV57 zwq>=mCaXwDcgoZxUfx>%tvtRap!4h_R)!Ty!1zdr;(PaHmzvvN_uiG6+m}UbIBr!o z?|VX7%}ayZE0`RyMFIQh}Teffcsk`msc2{p#2D;}~u`qy= z&*g3T*=3J2;(tazT{LCY-RaL)K63E?wX^p2`roZAitBIvWn@qT8}JvvolroMWzmzLGBgdUd1$`22>e_Q+e z=EJ_zWg3Ode#LAJw?GLXZAocnQKH3?b$srsW}i8?fBoyZt#hhsP4W(@$DCf^qIS5Y@?-s?D^I@n>Z@@~ zd0u&L2j`Ueb34As-rfIb<-K>@R$V_+?p<;d1ZLZBpdd0fyA)n`kLB6SXJ)?-1-M+z z)J^K?c;7k0VM&bH;hmhN1|Nh|H#&tq$Ua}Vrz5rcVvU~3gwrj@e^pO^r}^yB&rYA$ z8#L9ul$7Rd26mJCK?Tpo-TQvr*thy#?MHd`xVxe&S+B9nhHstz>7H?%WyPUQYje_k zs^oSX+X>ys&+0VQ>(dTweEZ~1K!!?`mRZ`Vrya}ta}PUu06W8=At8h0WcOok@BTh= z_U}9qEdTqMFMs=@hsV~%F71=Q`|UBae%$6KpU%(OT`>3ZBa3YvoFNJygY3lhGuKYZ zWp80&uy6)NujtqH@;l2?`jiEnLzhBA1% L`njxgN@xNAlYeAX literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_3.0.png b/calamares/src/modules/locale/images/timezone_3.0.png new file mode 100644 index 0000000000000000000000000000000000000000..5cdf8e04e09c0b9bbb014118652368b0b485c7eb GIT binary patch literal 21322 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 F{2Sg^dFcQE literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_4.0.png b/calamares/src/modules/locale/images/timezone_4.0.png new file mode 100644 index 0000000000000000000000000000000000000000..21493ae445f3c9ee9887204003ef98f63c9382f7 GIT binary patch literal 5105 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 literal 0 HcmV?d00001 diff --git a/calamares/src/modules/locale/images/timezone_5.0.png b/calamares/src/modules/locale/images/timezone_5.0.png new file mode 100644 index 0000000000000000000000000000000000000000..e6ddf2d7a90fbde59fb25c5fa6e4f135da8dcdc8 GIT binary patch literal 15038 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