summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.DS_Storebin8196 -> 0 bytes
-rw-r--r--.gitignore5
-rw-r--r--raveos-calamares-module/CMakeLists.txt1
-rw-r--r--raveos-calamares-module/PKGBUILD4
-rw-r--r--raveos-calamares-module/raveusers/CMakeLists.txt37
-rw-r--r--raveos-calamares-module/raveusers/RaveUsersBackend.cpp242
-rw-r--r--raveos-calamares-module/raveusers/RaveUsersBackend.h89
-rw-r--r--raveos-calamares-module/raveusers/RaveUsersJobs.cpp294
-rw-r--r--raveos-calamares-module/raveusers/RaveUsersJobs.h67
-rw-r--r--raveos-calamares-module/raveusers/RaveUsersViewStep.cpp253
-rw-r--r--raveos-calamares-module/raveusers/RaveUsersViewStep.h47
-rw-r--r--raveos-calamares-module/raveusers/module.desc5
-rw-r--r--raveos-calamares-module/raveusers/raveusers.conf26
-rw-r--r--raveos-calamares-module/raveusers/ui/PasswordField.qml41
-rw-r--r--raveos-calamares-module/raveusers/ui/Translations.qml58
-rw-r--r--raveos-calamares-module/raveusers/ui/qmldir2
-rwxr-xr-xraveos-calamares-module/raveusers/ui/raveos-logo.pngbin0 -> 302684 bytes
-rw-r--r--raveos-calamares-module/raveusers/ui/raveusers.qml286
-rw-r--r--raveos-calamares-theme/.DS_Storebin6148 -> 0 bytes
-rw-r--r--raveos-calamares-theme/REVIEW.md66
-rw-r--r--raveos-calamares-theme/etc/.DS_Storebin6148 -> 0 bytes
-rw-r--r--raveos-calamares-theme/etc/calamares/.DS_Storebin6148 -> 0 bytes
-rw-r--r--raveos-calamares-theme/etc/calamares/branding/.DS_Storebin6148 -> 0 bytes
-rw-r--r--raveos-calamares-theme/etc/calamares/branding/raveos/.DS_Storebin6148 -> 0 bytes
-rwxr-xr-xraveos-calamares-theme/etc/calamares/settings.conf4
-rw-r--r--raveos-updater/PKGBUILD28
-rw-r--r--raveos-updater/check.py83
-rw-r--r--raveos-updater/raveos-updater164
-rw-r--r--raveos-updater/raveos-updater.desktop12
-rw-r--r--raveos-updater/raveos-updater.hook11
-rw-r--r--raveos-updater/raveos-updater.install20
-rw-r--r--raveos-updater/raveos-updater.service6
-rw-r--r--raveos-updater/raveos-updater.timer10
33 files changed, 1857 insertions, 4 deletions
diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 23f73e2..0000000
--- a/.DS_Store
+++ /dev/null
Binary files differ
diff --git a/.gitignore b/.gitignore
index 20c4cb1..0698f0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,8 @@ src/
*.pkg.tar.zst.sig
*.pkg.tar.xz
*.pkg.tar.xz.sig
+
+# Python / macOS caches
+__pycache__/
+*.py[cod]
+.DS_Store
diff --git a/raveos-calamares-module/CMakeLists.txt b/raveos-calamares-module/CMakeLists.txt
index 1a56796..bcec971 100644
--- a/raveos-calamares-module/CMakeLists.txt
+++ b/raveos-calamares-module/CMakeLists.txt
@@ -7,3 +7,4 @@ find_package(Calamares REQUIRED)
add_subdirectory(wifi)
add_subdirectory(welcome)
add_subdirectory(desktopselect)
+add_subdirectory(raveusers)
diff --git a/raveos-calamares-module/PKGBUILD b/raveos-calamares-module/PKGBUILD
index 9006d8e..050a1bd 100644
--- a/raveos-calamares-module/PKGBUILD
+++ b/raveos-calamares-module/PKGBUILD
@@ -2,13 +2,13 @@
pkgname=raveos-calamares-module
pkgver=2.0.0
pkgrel=28
-pkgdesc="RaveOS Calamares modules: Wi-Fi, Welcome, Desktop Selector"
+pkgdesc="RaveOS Calamares modules: Wi-Fi, Welcome, Desktop Selector, Users"
arch=('x86_64')
options=('!debug')
url="https://git.rp1.hu/RaveOS"
license=('GPL')
-makedepends=('cmake' 'qt6-tools' 'kcoreaddons' 'calamares')
+makedepends=('cmake' 'qt6-tools' 'kcoreaddons' 'calamares' 'libxcrypt')
depends=('qt6-base' 'qt6-declarative' 'networkmanager' 'xorg-setxkbmap')
source=()
diff --git a/raveos-calamares-module/raveusers/CMakeLists.txt b/raveos-calamares-module/raveusers/CMakeLists.txt
new file mode 100644
index 0000000..680056a
--- /dev/null
+++ b/raveos-calamares-module/raveusers/CMakeLists.txt
@@ -0,0 +1,37 @@
+find_package(Qt6 REQUIRED COMPONENTS Core Widgets Quick QuickWidgets)
+
+calamares_add_plugin(raveusers
+ TYPE viewmodule
+ EXPORT_MACRO PLUGINDLLEXPORT_PRO
+ SOURCES
+ RaveUsersViewStep.cpp
+ RaveUsersBackend.cpp
+ RaveUsersJobs.cpp
+ LINK_LIBRARIES
+ Qt6::Core
+ Qt6::Widgets
+ Qt6::Quick
+ Qt6::QuickWidgets
+ Calamares::calamares
+ crypt
+)
+
+install(
+ FILES module.desc
+ DESTINATION lib/calamares/modules/raveusers
+)
+
+install(
+ FILES raveusers.conf
+ DESTINATION share/calamares/modules
+)
+
+install(
+ FILES
+ ui/raveusers.qml
+ ui/PasswordField.qml
+ ui/Translations.qml
+ ui/qmldir
+ ui/raveos-logo.png
+ DESTINATION lib/calamares/modules/raveusers/ui
+)
diff --git a/raveos-calamares-module/raveusers/RaveUsersBackend.cpp b/raveos-calamares-module/raveusers/RaveUsersBackend.cpp
new file mode 100644
index 0000000..6f8eea6
--- /dev/null
+++ b/raveos-calamares-module/raveusers/RaveUsersBackend.cpp
@@ -0,0 +1,242 @@
+#include "RaveUsersBackend.h"
+
+#include <QRegularExpression>
+
+namespace
+{
+const QRegularExpression USERNAME_RX( "^[a-z_][a-z0-9_-]*[$]?$" );
+constexpr int USERNAME_MAX_LENGTH = 31;
+
+const QRegularExpression HOSTNAME_RX( "^[a-zA-Z0-9][-a-zA-Z0-9_]*$" );
+constexpr int HOSTNAME_MIN_LENGTH = 2;
+constexpr int HOSTNAME_MAX_LENGTH = 63;
+}
+
+RaveUsersBackend::RaveUsersBackend( QObject* parent )
+ : QObject( parent )
+ , m_forbiddenLoginNames { QStringLiteral( "root" ), QStringLiteral( "nobody" ) }
+ , m_forbiddenHostNames { QStringLiteral( "localhost" ) }
+{
+ m_defaultGroups = { "lp", "video", "network", "storage", "wheel", "audio" };
+}
+
+QString
+RaveUsersBackend::usernameStatus() const
+{
+ if ( m_username.isEmpty() )
+ {
+ return QString();
+ }
+ if ( m_username.length() > USERNAME_MAX_LENGTH )
+ {
+ return tr( "Your username is too long." );
+ }
+ if ( m_username.indexOf( QRegularExpression( "^[a-z_]" ) ) != 0 )
+ {
+ return tr( "Your username must start with a lowercase letter or underscore." );
+ }
+ if ( m_username.indexOf( USERNAME_RX ) != 0 )
+ {
+ return tr( "Only lowercase letters, numbers, underscore and hyphen are allowed." );
+ }
+ if ( m_forbiddenLoginNames.contains( m_username, Qt::CaseInsensitive ) )
+ {
+ return tr( "'%1' is not allowed as username." ).arg( m_username );
+ }
+ return QString();
+}
+
+QString
+RaveUsersBackend::hostnameStatus() const
+{
+ 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." );
+ }
+ if ( m_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();
+}
+
+QString
+RaveUsersBackend::passwordStatus() const
+{
+ if ( m_password.isEmpty() )
+ {
+ return QString();
+ }
+ if ( m_password != m_passwordSecondary )
+ {
+ return tr( "Your passwords do not match!" );
+ }
+ return QString();
+}
+
+QString
+RaveUsersBackend::rootPasswordStatus() const
+{
+ if ( m_reuseRootPassword )
+ {
+ return QString();
+ }
+ if ( m_rootPassword.isEmpty() )
+ {
+ return QString();
+ }
+ if ( m_rootPassword != m_rootPasswordSecondary )
+ {
+ return tr( "Your root passwords do not match!" );
+ }
+ return QString();
+}
+
+bool
+RaveUsersBackend::isReady() const
+{
+ if ( m_username.isEmpty() || !usernameStatus().isEmpty() )
+ {
+ return false;
+ }
+ if ( m_hostname.isEmpty() || !hostnameStatus().isEmpty() )
+ {
+ return false;
+ }
+ if ( m_password.isEmpty() || !passwordStatus().isEmpty() )
+ {
+ return false;
+ }
+ if ( m_setRootPassword && !m_reuseRootPassword )
+ {
+ if ( m_rootPassword.isEmpty() || !rootPasswordStatus().isEmpty() )
+ {
+ return false;
+ }
+ }
+ return true;
+}
+
+QStringList
+RaveUsersBackend::groupsForThisUser() const
+{
+ QStringList groups = m_defaultGroups;
+ groups.removeDuplicates();
+ return groups;
+}
+
+void
+RaveUsersBackend::setUsername( const QString& s )
+{
+ if ( s != m_username )
+ {
+ m_username = s;
+ emit usernameChanged();
+ emit statusChanged();
+
+ if ( !m_customHostname )
+ {
+ const QString generated = s.isEmpty() ? QString() : s + QStringLiteral( "-RaveOS-PC" );
+ if ( generated != m_hostname )
+ {
+ m_hostname = generated;
+ emit hostnameChanged();
+ emit statusChanged();
+ }
+ }
+
+ checkReady();
+ }
+}
+
+void
+RaveUsersBackend::setHostname( const QString& s )
+{
+ m_customHostname = true;
+ if ( s != m_hostname )
+ {
+ m_hostname = s;
+ emit hostnameChanged();
+ emit statusChanged();
+ checkReady();
+ }
+}
+
+void
+RaveUsersBackend::setPassword( const QString& s )
+{
+ if ( s != m_password )
+ {
+ m_password = s;
+ emit statusChanged();
+ checkReady();
+ }
+}
+
+void
+RaveUsersBackend::setPasswordSecondary( const QString& s )
+{
+ if ( s != m_passwordSecondary )
+ {
+ m_passwordSecondary = s;
+ emit statusChanged();
+ checkReady();
+ }
+}
+
+void
+RaveUsersBackend::setRootPasswordText( const QString& s )
+{
+ if ( s != m_rootPassword )
+ {
+ m_rootPassword = s;
+ emit statusChanged();
+ checkReady();
+ }
+}
+
+void
+RaveUsersBackend::setRootPasswordSecondaryText( const QString& s )
+{
+ if ( s != m_rootPasswordSecondary )
+ {
+ m_rootPasswordSecondary = s;
+ emit statusChanged();
+ checkReady();
+ }
+}
+
+void
+RaveUsersBackend::setReuseRootPassword( bool b )
+{
+ if ( b != m_reuseRootPassword )
+ {
+ m_reuseRootPassword = b;
+ emit reuseRootPasswordChanged();
+ emit statusChanged();
+ checkReady();
+ }
+}
+
+void
+RaveUsersBackend::checkReady()
+{
+ const bool ready = isReady();
+ if ( ready != m_lastReady )
+ {
+ m_lastReady = ready;
+ emit readyChanged();
+ }
+}
diff --git a/raveos-calamares-module/raveusers/RaveUsersBackend.h b/raveos-calamares-module/raveusers/RaveUsersBackend.h
new file mode 100644
index 0000000..4e06f47
--- /dev/null
+++ b/raveos-calamares-module/raveusers/RaveUsersBackend.h
@@ -0,0 +1,89 @@
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <QStringList>
+
+class RaveUsersBackend : public QObject
+{
+ Q_OBJECT
+
+ Q_PROPERTY( QString username READ username NOTIFY usernameChanged )
+ Q_PROPERTY( QString hostname READ hostname NOTIFY hostnameChanged )
+ Q_PROPERTY( QString usernameStatus READ usernameStatus NOTIFY statusChanged )
+ Q_PROPERTY( QString hostnameStatus READ hostnameStatus NOTIFY statusChanged )
+ Q_PROPERTY( QString passwordStatus READ passwordStatus NOTIFY statusChanged )
+ Q_PROPERTY( QString rootPasswordStatus READ rootPasswordStatus NOTIFY statusChanged )
+ Q_PROPERTY( bool ready READ isReady NOTIFY readyChanged )
+ Q_PROPERTY( bool reuseRootPassword READ reuseRootPassword NOTIFY reuseRootPasswordChanged )
+ Q_PROPERTY( bool rootReuseVisible READ rootReuseVisible CONSTANT )
+
+public:
+ explicit RaveUsersBackend( QObject* parent = nullptr );
+
+ QString username() const { return m_username; }
+ QString hostname() const { return m_hostname; }
+ QString usernameStatus() const;
+ QString hostnameStatus() const;
+ QString passwordStatus() const;
+ QString rootPasswordStatus() const;
+ bool isReady() const;
+ bool reuseRootPassword() const { return m_reuseRootPassword; }
+ bool rootReuseVisible() const { return m_setRootPassword; }
+
+ QString userPassword() const { return m_password; }
+ QString rootPassword() const { return m_reuseRootPassword ? m_password : m_rootPassword; }
+
+ QString userShell() const { return m_userShell; }
+ QStringList groupsForThisUser() const;
+ QString sudoersGroup() const { return m_sudoersGroup; }
+ bool sudoersConfigureWithGroup() const { return m_sudoersConfigureWithGroup; }
+ int homeUMask() const { return m_homeUMask; }
+ QStringList defaultGroups() const { return m_defaultGroups; }
+
+ Q_INVOKABLE void setUsername( const QString& s );
+ Q_INVOKABLE void setHostname( const QString& s );
+ Q_INVOKABLE void setPassword( const QString& s );
+ Q_INVOKABLE void setPasswordSecondary( const QString& s );
+ Q_INVOKABLE void setRootPasswordText( const QString& s );
+ Q_INVOKABLE void setRootPasswordSecondaryText( const QString& s );
+ Q_INVOKABLE void setReuseRootPassword( bool b );
+
+ void setForbiddenLoginNames( const QStringList& names ) { m_forbiddenLoginNames = names; }
+ void setForbiddenHostNames( const QStringList& names ) { m_forbiddenHostNames = names; }
+ void setUserShell( const QString& shell ) { m_userShell = shell; }
+ void setSudoersGroup( const QString& group ) { m_sudoersGroup = group; }
+ void setSudoersConfigureWithGroup( bool b ) { m_sudoersConfigureWithGroup = b; }
+ void setSetRootPassword( bool b ) { m_setRootPassword = b; }
+ void setHomeUMask( int umask ) { m_homeUMask = umask; }
+ void setDefaultGroups( const QStringList& groups ) { m_defaultGroups = groups; }
+
+signals:
+ void usernameChanged();
+ void hostnameChanged();
+ void statusChanged();
+ void readyChanged();
+ void reuseRootPasswordChanged();
+
+private:
+ void checkReady();
+
+ QString m_username;
+ QString m_hostname;
+ QString m_password;
+ QString m_passwordSecondary;
+ QString m_rootPassword;
+ QString m_rootPasswordSecondary;
+ bool m_customHostname = false;
+ bool m_reuseRootPassword = true;
+ bool m_setRootPassword = true;
+
+ QStringList m_forbiddenLoginNames;
+ QStringList m_forbiddenHostNames;
+ QString m_userShell = QStringLiteral( "/bin/bash" );
+ QString m_sudoersGroup;
+ bool m_sudoersConfigureWithGroup = true;
+ int m_homeUMask = -1;
+ QStringList m_defaultGroups;
+ bool m_lastReady = false;
+};
diff --git a/raveos-calamares-module/raveusers/RaveUsersJobs.cpp b/raveos-calamares-module/raveusers/RaveUsersJobs.cpp
new file mode 100644
index 0000000..1923c38
--- /dev/null
+++ b/raveos-calamares-module/raveusers/RaveUsersJobs.cpp
@@ -0,0 +1,294 @@
+#include "RaveUsersJobs.h"
+
+#include "RaveUsersBackend.h"
+
+#include "GlobalStorage.h"
+#include "JobQueue.h"
+#include "utils/Logger.h"
+#include "utils/Permissions.h"
+#include "utils/System.h"
+
+#include <QDir>
+#include <QFile>
+#include <QRandomGenerator>
+#include <QSet>
+
+#include <crypt.h>
+
+using WriteMode = Calamares::System::WriteMode;
+
+static QDir
+targetRootDir()
+{
+ Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
+ return QDir( gs->value( "rootMountPoint" ).toString() );
+}
+
+RaveUsersCreateUserJob::RaveUsersCreateUserJob( const RaveUsersBackend* backend )
+ : m_backend( backend )
+{
+}
+
+QString
+RaveUsersCreateUserJob::prettyName() const
+{
+ return tr( "Create user %1" ).arg( m_backend->username() );
+}
+
+QString
+RaveUsersCreateUserJob::prettyStatusMessage() const
+{
+ return tr( "Creating user %1…", "@status" ).arg( m_backend->username() );
+}
+
+Calamares::JobResult
+RaveUsersCreateUserJob::exec()
+{
+ const QString login = m_backend->username();
+
+ QStringList useraddCommand { "useradd", "-m", "-U" };
+ const QString shell = m_backend->userShell();
+ if ( !shell.isEmpty() )
+ {
+ useraddCommand << "-s" << shell;
+ }
+
+ useraddCommand << "-c" << login;
+ if ( m_backend->homeUMask() >= 0 )
+ {
+ useraddCommand << "-K" << QStringLiteral( "UMASK=%1" ).arg( m_backend->homeUMask(), 3, 8, QChar( '0' ) );
+ }
+ useraddCommand << login;
+
+ auto result = Calamares::System::instance()->targetEnvCommand( useraddCommand );
+ if ( result.getExitCode() )
+ {
+ cError() << "useradd failed" << result.getExitCode();
+ return result.explainProcess( useraddCommand, std::chrono::seconds( 10 ) );
+ }
+
+ const QStringList groups = m_backend->groupsForThisUser();
+ if ( !groups.isEmpty() )
+ {
+ QStringList usermodCommand { "usermod", "-aG", groups.join( ',' ), login };
+ result = Calamares::System::instance()->targetEnvCommand( usermodCommand );
+ if ( result.getExitCode() )
+ {
+ cError() << "usermod failed" << result.getExitCode();
+ return result.explainProcess( usermodCommand, std::chrono::seconds( 10 ) );
+ }
+ }
+
+ const QString userGroup = QStringLiteral( "%1:%2" ).arg( login, login );
+ const QString homeDir = QStringLiteral( "/home/%1" ).arg( login );
+ result = Calamares::System::instance()->targetEnvCommand( { "chown", "-R", userGroup, homeDir } );
+ if ( result.getExitCode() )
+ {
+ cError() << "chown failed" << result.getExitCode();
+ return result.explainProcess( "chown", std::chrono::seconds( 10 ) );
+ }
+
+ return Calamares::JobResult::ok();
+}
+
+RaveUsersSetPasswordJob::RaveUsersSetPasswordJob( const QString& userName, const QString& password )
+ : m_userName( userName )
+ , m_password( password )
+{
+}
+
+QString
+RaveUsersSetPasswordJob::prettyName() const
+{
+ return tr( "Set password for user %1" ).arg( m_userName );
+}
+
+QString
+RaveUsersSetPasswordJob::prettyStatusMessage() const
+{
+ return tr( "Setting password for user %1…", "@status" ).arg( m_userName );
+}
+
+static QString
+makeSha512Salt( int length = 16 )
+{
+ static const QString chars = QStringLiteral( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./" );
+ QString salt;
+ salt.reserve( length );
+ for ( int i = 0; i < length; ++i )
+ {
+ salt += chars.at( QRandomGenerator::global()->bounded( chars.size() ) );
+ }
+ return QStringLiteral( "$6$" ) + salt + QLatin1Char( '$' );
+}
+
+Calamares::JobResult
+RaveUsersSetPasswordJob::exec()
+{
+ QDir destDir = targetRootDir();
+ if ( !destDir.exists() )
+ {
+ return Calamares::JobResult::error( tr( "Bad destination system path." ),
+ tr( "rootMountPoint is %1" ).arg( destDir.absolutePath() ) );
+ }
+
+ if ( m_userName == QLatin1String( "root" ) && m_password.isEmpty() )
+ {
+ 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();
+ }
+
+ const QString salt = makeSha512Salt();
+ const QByteArray hash = crypt( m_password.toUtf8().constData(), salt.toUtf8().constData() );
+ if ( hash.isEmpty() )
+ {
+ return Calamares::JobResult::error( tr( "Cannot hash password." ) );
+ }
+
+ int ec = Calamares::System::instance()->targetEnvCall(
+ { "usermod", "-p", QString::fromLatin1( hash ), 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();
+}
+
+RaveUsersSetHostnameJob::RaveUsersSetHostnameJob( const QString& hostname )
+ : m_hostname( hostname )
+{
+}
+
+QString
+RaveUsersSetHostnameJob::prettyName() const
+{
+ return tr( "Set hostname %1" ).arg( m_hostname );
+}
+
+Calamares::JobResult
+RaveUsersSetHostnameJob::exec()
+{
+ if ( !Calamares::System::instance()->createTargetFile( QStringLiteral( "/etc/hostname" ),
+ ( m_hostname + '\n' ).toUtf8(),
+ WriteMode::Overwrite ) )
+ {
+ return Calamares::JobResult::error( tr( "Cannot write hostname to target system" ) );
+ }
+
+ const QString 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
+# This host address
+127.0.1.1 %1
+)" )
+ .arg( m_hostname );
+
+ if ( !Calamares::System::instance()->createTargetFile(
+ QStringLiteral( "/etc/hosts" ), hosts.toUtf8(), WriteMode::Overwrite ) )
+ {
+ return Calamares::JobResult::error( tr( "Cannot write hosts file to target system" ) );
+ }
+
+ return Calamares::JobResult::ok();
+}
+
+RaveUsersSetupSudoJob::RaveUsersSetupSudoJob( const QString& group, bool configureWithGroup )
+ : m_group( group )
+ , m_configureWithGroup( configureWithGroup )
+{
+}
+
+QString
+RaveUsersSetupSudoJob::prettyName() const
+{
+ return tr( "Configuring sudo users…", "@status" );
+}
+
+Calamares::JobResult
+RaveUsersSetupSudoJob::exec()
+{
+ if ( m_group.isEmpty() )
+ {
+ return Calamares::JobResult::ok();
+ }
+
+ const QString designator = m_configureWithGroup ? QStringLiteral( "(ALL:ALL)" ) : QStringLiteral( "(ALL)" );
+ const QString line = QChar( '%' ) + QStringLiteral( "%1 ALL=%2 ALL\n" ).arg( m_group, designator );
+
+ auto fileResult = Calamares::System::instance()->createTargetFile(
+ QStringLiteral( "/etc/sudoers.d/10-installer" ), line.toUtf8().constData(), 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();
+}
+
+RaveUsersSetupGroupsJob::RaveUsersSetupGroupsJob( const QStringList& groups )
+ : m_groups( groups )
+{
+}
+
+QString
+RaveUsersSetupGroupsJob::prettyName() const
+{
+ return tr( "Preparing groups…", "@status" );
+}
+
+Calamares::JobResult
+RaveUsersSetupGroupsJob::exec()
+{
+ QDir targetRoot = targetRootDir();
+
+ QSet< QString > existing;
+ QFile groupFile( targetRoot.absoluteFilePath( "etc/group" ) );
+ if ( groupFile.open( QIODevice::ReadOnly | QIODevice::Text ) )
+ {
+ while ( !groupFile.atEnd() )
+ {
+ const QString line = QString::fromLocal8Bit( groupFile.readLine() );
+ if ( line.startsWith( '#' ) )
+ {
+ continue;
+ }
+ const int idx = line.indexOf( ':' );
+ if ( idx > 0 )
+ {
+ existing.insert( line.left( idx ) );
+ }
+ }
+ }
+
+ for ( const QString& group : m_groups )
+ {
+ if ( group.isEmpty() || existing.contains( group ) )
+ {
+ continue;
+ }
+ QStringList cmd { "groupadd", "--system", group };
+ if ( Calamares::System::instance()->targetEnvCall( cmd ) )
+ {
+ cWarning() << "groupadd failed for" << group;
+ }
+ }
+
+ return Calamares::JobResult::ok();
+}
diff --git a/raveos-calamares-module/raveusers/RaveUsersJobs.h b/raveos-calamares-module/raveusers/RaveUsersJobs.h
new file mode 100644
index 0000000..c98a0b2
--- /dev/null
+++ b/raveos-calamares-module/raveusers/RaveUsersJobs.h
@@ -0,0 +1,67 @@
+#pragma once
+
+#include <Job.h>
+
+#include <QString>
+#include <QStringList>
+
+class RaveUsersBackend;
+
+class RaveUsersCreateUserJob : public Calamares::Job
+{
+public:
+ explicit RaveUsersCreateUserJob( const RaveUsersBackend* backend );
+ QString prettyName() const override;
+ QString prettyStatusMessage() const override;
+ Calamares::JobResult exec() override;
+
+private:
+ const RaveUsersBackend* m_backend;
+};
+
+class RaveUsersSetPasswordJob : public Calamares::Job
+{
+public:
+ RaveUsersSetPasswordJob( const QString& userName, const QString& password );
+ QString prettyName() const override;
+ QString prettyStatusMessage() const override;
+ Calamares::JobResult exec() override;
+
+private:
+ QString m_userName;
+ QString m_password;
+};
+
+class RaveUsersSetHostnameJob : public Calamares::Job
+{
+public:
+ explicit RaveUsersSetHostnameJob( const QString& hostname );
+ QString prettyName() const override;
+ Calamares::JobResult exec() override;
+
+private:
+ QString m_hostname;
+};
+
+class RaveUsersSetupSudoJob : public Calamares::Job
+{
+public:
+ RaveUsersSetupSudoJob( const QString& group, bool configureWithGroup );
+ QString prettyName() const override;
+ Calamares::JobResult exec() override;
+
+private:
+ QString m_group;
+ bool m_configureWithGroup;
+};
+
+class RaveUsersSetupGroupsJob : public Calamares::Job
+{
+public:
+ explicit RaveUsersSetupGroupsJob( const QStringList& groups );
+ QString prettyName() const override;
+ Calamares::JobResult exec() override;
+
+private:
+ QStringList m_groups;
+};
diff --git a/raveos-calamares-module/raveusers/RaveUsersViewStep.cpp b/raveos-calamares-module/raveusers/RaveUsersViewStep.cpp
new file mode 100644
index 0000000..4e21e65
--- /dev/null
+++ b/raveos-calamares-module/raveusers/RaveUsersViewStep.cpp
@@ -0,0 +1,253 @@
+#include "RaveUsersViewStep.h"
+
+#include "RaveUsersBackend.h"
+#include "RaveUsersJobs.h"
+
+#include "GlobalStorage.h"
+#include "JobQueue.h"
+#include "utils/Logger.h"
+#include "utils/String.h"
+#include "utils/Variant.h"
+
+#include <QDir>
+#include <QFile>
+#include <QLocale>
+#include <QQmlContext>
+#include <QQmlEngine>
+#include <QQuickItem>
+#include <QQuickWidget>
+#include <QVBoxLayout>
+
+CALAMARES_PLUGIN_FACTORY_DEFINITION( RaveUsersViewStepFactory, registerPlugin< RaveUsersViewStep >(); )
+
+RaveUsersViewStep::RaveUsersViewStep( QObject* parent )
+ : Calamares::ViewStep( parent )
+ , m_backend( new RaveUsersBackend( this ) )
+{
+ connect( m_backend, &RaveUsersBackend::readyChanged, this, [this]() {
+ emit nextStatusChanged( m_backend->isReady() );
+ } );
+}
+
+RaveUsersViewStep::~RaveUsersViewStep()
+{
+ if ( m_widget && m_widget->parent() == nullptr )
+ {
+ delete m_widget;
+ }
+}
+
+QString
+RaveUsersViewStep::prettyName() const
+{
+ return tr( "User" );
+}
+
+void
+RaveUsersViewStep::buildWidget()
+{
+ m_widget = new QWidget();
+ auto* layout = new QVBoxLayout( m_widget );
+ layout->setContentsMargins( 0, 0, 0, 0 );
+
+ m_quickWidget = new QQuickWidget();
+ m_quickWidget->setResizeMode( QQuickWidget::SizeRootObjectToView );
+
+ m_quickWidget->setClearColor( QColor( 0x40, 0x40, 0x40 ) );
+
+ QString modulePath = QStringLiteral( "/usr/lib/calamares/modules/raveusers/ui" );
+ if ( !QDir( modulePath ).exists() )
+ {
+ modulePath = QDir::currentPath() + QStringLiteral( "/raveusers/ui" );
+ }
+
+ m_quickWidget->engine()->addImportPath( modulePath );
+ m_quickWidget->rootContext()->setContextProperty( "backend", m_backend );
+
+ m_locale = getLocale();
+ m_quickWidget->rootContext()->setContextProperty( "systemLocale", m_locale );
+
+ QString qmlPath = QStringLiteral( "/usr/lib/calamares/modules/raveusers/ui/raveusers.qml" );
+ if ( !QFile::exists( qmlPath ) )
+ {
+ qmlPath = QDir::currentPath() + QStringLiteral( "/raveusers/ui/raveusers.qml" );
+ }
+
+ m_quickWidget->setSource( QUrl::fromLocalFile( qmlPath ) );
+ layout->addWidget( m_quickWidget );
+}
+
+QWidget*
+RaveUsersViewStep::widget()
+{
+ if ( !m_widget )
+ {
+ buildWidget();
+ }
+ return m_widget;
+}
+
+bool
+RaveUsersViewStep::isNextEnabled() const
+{
+ return m_backend && m_backend->isReady();
+}
+
+bool
+RaveUsersViewStep::isBackEnabled() const
+{
+ return true;
+}
+
+bool
+RaveUsersViewStep::isAtBeginning() const
+{
+ return true;
+}
+
+bool
+RaveUsersViewStep::isAtEnd() const
+{
+ return true;
+}
+
+Calamares::JobList
+RaveUsersViewStep::jobs() const
+{
+ Calamares::JobList jobs;
+
+ if ( !m_backend || !m_backend->isReady() )
+ {
+ return jobs;
+ }
+
+ if ( !m_backend->sudoersGroup().isEmpty() )
+ {
+ jobs.append( Calamares::job_ptr(
+ new RaveUsersSetupSudoJob( m_backend->sudoersGroup(), m_backend->sudoersConfigureWithGroup() ) ) );
+ }
+
+ jobs.append( Calamares::job_ptr( new RaveUsersSetupGroupsJob( m_backend->defaultGroups() ) ) );
+ jobs.append( Calamares::job_ptr( new RaveUsersCreateUserJob( m_backend ) ) );
+ jobs.append( Calamares::job_ptr( new RaveUsersSetPasswordJob( m_backend->username(), m_backend->userPassword() ) ) );
+ jobs.append( Calamares::job_ptr( new RaveUsersSetPasswordJob( QStringLiteral( "root" ), m_backend->rootPassword() ) ) );
+ jobs.append( Calamares::job_ptr( new RaveUsersSetHostnameJob( m_backend->hostname() ) ) );
+
+ return jobs;
+}
+
+void
+RaveUsersViewStep::onActivate()
+{
+ const QString newLocale = getLocale();
+ if ( newLocale != m_locale )
+ {
+ m_locale = newLocale;
+ if ( m_quickWidget )
+ {
+ m_quickWidget->rootContext()->setContextProperty( "systemLocale", m_locale );
+ }
+ }
+
+ if ( m_quickWidget )
+ {
+ QQuickItem* root = m_quickWidget->rootObject();
+ if ( root )
+ {
+ QMetaObject::invokeMethod( root, "updateLanguage", Qt::DirectConnection );
+ }
+ }
+}
+
+void
+RaveUsersViewStep::onLeave()
+{
+ if ( !m_backend || !m_backend->isReady() )
+ {
+ return;
+ }
+
+ Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
+ if ( !gs )
+ {
+ return;
+ }
+
+ const QString username = m_backend->username();
+ gs->insert( "username", username );
+ gs->insert( "hostname", m_backend->hostname() );
+ gs->insert( "fullname", username );
+ gs->insert( "password", Calamares::String::obscure( m_backend->userPassword() ) );
+ gs->insert( "setRootPassword", m_backend->rootReuseVisible() );
+ gs->insert( "reuseRootPassword", m_backend->reuseRootPassword() );
+}
+
+void
+RaveUsersViewStep::setConfigurationMap( const QVariantMap& config )
+{
+ const QVariantMap userSettings = config.value( "user" ).toMap();
+ QString shell = Calamares::getString( userSettings, "shell" );
+ m_backend->setUserShell( shell.isEmpty() ? QStringLiteral( "/bin/bash" ) : shell );
+
+ QStringList forbiddenLogin = Calamares::getStringList( userSettings, "forbidden_names" );
+ forbiddenLogin << QStringLiteral( "root" ) << QStringLiteral( "nobody" );
+ forbiddenLogin.removeDuplicates();
+ m_backend->setForbiddenLoginNames( forbiddenLogin );
+
+ const QVariantMap hostnameSettings = config.value( "hostname" ).toMap();
+ QStringList forbiddenHost = Calamares::getStringList( hostnameSettings, "forbidden_names" );
+ forbiddenHost << QStringLiteral( "localhost" );
+ forbiddenHost.removeDuplicates();
+ m_backend->setForbiddenHostNames( forbiddenHost );
+
+ m_backend->setSudoersGroup( Calamares::getString( config, "sudoersGroup" ) );
+ m_backend->setSudoersConfigureWithGroup( Calamares::getBool( config, "sudoersConfigureWithGroup", true ) );
+ m_backend->setSetRootPassword( Calamares::getBool( config, "setRootPassword", true ) );
+
+ QStringList groups;
+ for ( const auto& v : config.value( "defaultGroups" ).toList() )
+ {
+ if ( v.typeId() == QMetaType::QString )
+ {
+ groups << v.toString();
+ }
+ else if ( v.typeId() == QMetaType::QVariantMap )
+ {
+ const QString name = v.toMap().value( "name" ).toString();
+ if ( !name.isEmpty() )
+ {
+ groups << name;
+ }
+ }
+ }
+ if ( !groups.isEmpty() )
+ {
+ m_backend->setDefaultGroups( groups );
+ }
+}
+
+QString
+RaveUsersViewStep::getLocale() const
+{
+ Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
+ if ( gs )
+ {
+ if ( gs->contains( "locale" ) )
+ {
+ const QString loc = gs->value( "locale" ).toString();
+ if ( !loc.isEmpty() )
+ {
+ return loc;
+ }
+ }
+ if ( gs->contains( "localeConf" ) )
+ {
+ const QString lang = gs->value( "localeConf" ).toMap().value( "LANG" ).toString();
+ if ( !lang.isEmpty() )
+ {
+ return lang;
+ }
+ }
+ }
+ return QLocale::system().name();
+}
diff --git a/raveos-calamares-module/raveusers/RaveUsersViewStep.h b/raveos-calamares-module/raveusers/RaveUsersViewStep.h
new file mode 100644
index 0000000..67593ac
--- /dev/null
+++ b/raveos-calamares-module/raveusers/RaveUsersViewStep.h
@@ -0,0 +1,47 @@
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <QStringList>
+#include <QWidget>
+
+#include <Job.h>
+#include <utils/PluginFactory.h>
+#include <viewpages/ViewStep.h>
+
+class QQuickWidget;
+class RaveUsersBackend;
+
+class PLUGINDLLEXPORT RaveUsersViewStep : public Calamares::ViewStep
+{
+ Q_OBJECT
+
+public:
+ explicit RaveUsersViewStep( QObject* parent = nullptr );
+ ~RaveUsersViewStep() 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;
+ void onActivate() override;
+ void onLeave() override;
+
+private:
+ void buildWidget();
+ QString getLocale() const;
+
+ QWidget* m_widget = nullptr;
+ QQuickWidget* m_quickWidget = nullptr;
+ RaveUsersBackend* m_backend = nullptr;
+ QString m_locale;
+};
+
+CALAMARES_PLUGIN_FACTORY_DECLARATION( RaveUsersViewStepFactory )
diff --git a/raveos-calamares-module/raveusers/module.desc b/raveos-calamares-module/raveusers/module.desc
new file mode 100644
index 0000000..143b7ec
--- /dev/null
+++ b/raveos-calamares-module/raveusers/module.desc
@@ -0,0 +1,5 @@
+---
+type: "viewmodule"
+name: "raveusers"
+interface: "qtplugin"
+load: "libcalamares_viewmodule_raveusers.so"
diff --git a/raveos-calamares-module/raveusers/raveusers.conf b/raveos-calamares-module/raveusers/raveusers.conf
new file mode 100644
index 0000000..8985cbe
--- /dev/null
+++ b/raveos-calamares-module/raveusers/raveusers.conf
@@ -0,0 +1,26 @@
+---
+defaultGroups:
+ - name: users
+ must_exist: true
+ system: true
+ - lp
+ - video
+ - network
+ - storage
+ - name: wheel
+ must_exist: false
+ system: true
+ - audio
+ - plugdev
+
+sudoersGroup: wheel
+sudoersConfigureWithGroup: true
+
+setRootPassword: true
+
+user:
+ shell: /bin/bash
+ forbidden_names: [ root ]
+
+hostname:
+ forbidden_names: [ localhost ]
diff --git a/raveos-calamares-module/raveusers/ui/PasswordField.qml b/raveos-calamares-module/raveusers/ui/PasswordField.qml
new file mode 100644
index 0000000..0dffb49
--- /dev/null
+++ b/raveos-calamares-module/raveusers/ui/PasswordField.qml
@@ -0,0 +1,41 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+
+TextField {
+ id: field
+ property bool reveal: false
+ signal revealClicked()
+
+ implicitHeight: 52
+ leftPadding: 14
+ rightPadding: 46
+ echoMode: reveal ? TextInput.Normal : TextInput.Password
+ color: "#ffffff"
+ placeholderTextColor: "#5b6270"
+ font.pixelSize: 15
+
+ background: Rectangle {
+ radius: 7
+ color: "#303030"
+ border.color: field.activeFocus ? "#3d7839" : "#303030"
+ border.width: field.activeFocus ? 1.5 : 1
+ }
+
+ Text {
+ text: "\uD83D\uDC41"
+ font.pixelSize: 16
+ color: field.reveal ? "#3d7839" : "#9ca3af"
+ anchors.right: parent.right
+ anchors.rightMargin: 14
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ MouseArea {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: 40
+ height: 40
+ cursorShape: Qt.PointingHandCursor
+ onClicked: field.revealClicked()
+ }
+}
diff --git a/raveos-calamares-module/raveusers/ui/Translations.qml b/raveos-calamares-module/raveusers/ui/Translations.qml
new file mode 100644
index 0000000..cb267b4
--- /dev/null
+++ b/raveos-calamares-module/raveusers/ui/Translations.qml
@@ -0,0 +1,58 @@
+pragma Singleton
+import QtQuick 2.15
+
+QtObject {
+ id: root
+
+ property string title: "Create user"
+ property string description: "Set up the computer's primary user account. This account has full administrator (root / sudo) privileges."
+ property string usernameLabel: "Username"
+ property string hostnameLabel: "Computer name"
+ property string passwordLabel: "Password"
+ property string passwordPlaceholder: "Password"
+ property string passwordConfirmLabel: "Confirm password"
+ property string reuseRootLabel: "Use the same password for the administrator (root)"
+ property string rootPasswordLabel: "Root password"
+ property string rootPasswordPlaceholder: "Root password"
+ property string rootPasswordConfirmLabel: "Confirm root password"
+
+ function setLanguage(locale) {
+ if (locale.startsWith("hu")) {
+ title = "Felhasználó létrehozása"
+ description = "Állítsd be a számítógép elsődleges felhasználói fiókját. Ez a fiók teljes rendszergazdai (root / sudo) jogosultsággal rendelkezik."
+ usernameLabel = "Felhasználónév"
+ hostnameLabel = "Számítógépnév"
+ passwordLabel = "Jelszó"
+ passwordPlaceholder = "Jelszó"
+ passwordConfirmLabel = "Jelszó megerősítése"
+ reuseRootLabel = "Ugyanaz a jelszó az adminisztrátorhoz (root)"
+ rootPasswordLabel = "Root jelszó"
+ rootPasswordPlaceholder = "Root jelszó"
+ rootPasswordConfirmLabel = "Root jelszó megerősítése"
+ } else if (locale.startsWith("de")) {
+ title = "Benutzer erstellen"
+ description = "Richte das primäre Benutzerkonto des Computers ein. Dieses Konto hat volle Administratorrechte (root / sudo)."
+ usernameLabel = "Benutzername"
+ hostnameLabel = "Computername"
+ passwordLabel = "Passwort"
+ passwordPlaceholder = "Passwort"
+ passwordConfirmLabel = "Passwort bestätigen"
+ reuseRootLabel = "Dasselbe Passwort für den Administrator (root)"
+ rootPasswordLabel = "Root-Passwort"
+ rootPasswordPlaceholder = "Root-Passwort"
+ rootPasswordConfirmLabel = "Root-Passwort bestätigen"
+ } else {
+ title = "Create user"
+ description = "Set up the computer's primary user account. This account has full administrator (root / sudo) privileges."
+ usernameLabel = "Username"
+ hostnameLabel = "Computer name"
+ passwordLabel = "Password"
+ passwordPlaceholder = "Password"
+ passwordConfirmLabel = "Confirm password"
+ reuseRootLabel = "Use the same password for the administrator (root)"
+ rootPasswordLabel = "Root password"
+ rootPasswordPlaceholder = "Root password"
+ rootPasswordConfirmLabel = "Confirm root password"
+ }
+ }
+}
diff --git a/raveos-calamares-module/raveusers/ui/qmldir b/raveos-calamares-module/raveusers/ui/qmldir
new file mode 100644
index 0000000..c87b787
--- /dev/null
+++ b/raveos-calamares-module/raveusers/ui/qmldir
@@ -0,0 +1,2 @@
+module RaveUsersModule
+singleton Translations 1.0 Translations.qml
diff --git a/raveos-calamares-module/raveusers/ui/raveos-logo.png b/raveos-calamares-module/raveusers/ui/raveos-logo.png
new file mode 100755
index 0000000..9903765
--- /dev/null
+++ b/raveos-calamares-module/raveusers/ui/raveos-logo.png
Binary files differ
diff --git a/raveos-calamares-module/raveusers/ui/raveusers.qml b/raveos-calamares-module/raveusers/ui/raveusers.qml
new file mode 100644
index 0000000..25e090f
--- /dev/null
+++ b/raveos-calamares-module/raveusers/ui/raveusers.qml
@@ -0,0 +1,286 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "." as RaveUsersModule
+
+Rectangle {
+ id: root
+ color: "#2B2B2B"
+ radius: 14
+
+ readonly property color cCardBg: "#20232a"
+ readonly property color cCardBorder: "#333947"
+ readonly property color cInputBg: "#303030"
+ readonly property color cInputBorder: "#303030"
+ readonly property color cAccentGreen: "#3d7839"
+ readonly property color cTextWhite: "#ffffff"
+ readonly property color cTextMuted: "#8b93a3"
+ readonly property color cTextFaint: "#5b6270"
+ readonly property color cErrorRed: "#ff6b6b"
+
+ property bool showUserPassword: false
+ property bool showRootPassword: false
+
+ function updateLanguage() {
+ RaveUsersModule.Translations.setLanguage(systemLocale)
+ }
+
+ Component.onCompleted: {
+ RaveUsersModule.Translations.setLanguage(systemLocale)
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 40
+ spacing: 0
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ColumnLayout {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: 10
+ width: Math.min(parent.width - 40, 540)
+ spacing: 16
+
+ Image {
+ source: "raveos-logo.png"
+ sourceSize.width: 96
+ sourceSize.height: 96
+ fillMode: Image.PreserveAspectFit
+ Layout.preferredWidth: 96
+ Layout.preferredHeight: 96
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Text {
+ text: RaveUsersModule.Translations.title
+ font.pixelSize: 28
+ font.bold: true
+ color: cTextWhite
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Text {
+ text: RaveUsersModule.Translations.description
+ font.pixelSize: 15
+ lineHeight: 1.45
+ color: cTextMuted
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ Item { height: 6 }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 6
+
+ Text {
+ text: RaveUsersModule.Translations.usernameLabel
+ color: "#c7ccd6"
+ font.pixelSize: 14
+ font.weight: Font.Medium
+ }
+
+ TextField {
+ id: usernameField
+ Layout.fillWidth: true
+ implicitHeight: 52
+ leftPadding: 14
+ rightPadding: 14
+ text: backend.username
+ placeholderText: "raveuser"
+ color: cTextWhite
+ placeholderTextColor: "#5b6270"
+ font.pixelSize: 15
+ background: Rectangle {
+ radius: 7
+ color: cInputBg
+ border.color: usernameField.activeFocus ? cAccentGreen : cInputBorder
+ border.width: usernameField.activeFocus ? 1.5 : 1
+ }
+ onTextEdited: backend.setUsername(text)
+ }
+
+ Text {
+ visible: backend.usernameStatus !== ""
+ text: backend.usernameStatus
+ color: cErrorRed
+ font.pixelSize: 12
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 6
+
+ Text {
+ text: RaveUsersModule.Translations.hostnameLabel
+ color: "#c7ccd6"
+ font.pixelSize: 14
+ font.weight: Font.Medium
+ }
+
+ TextField {
+ id: hostnameField
+ Layout.fillWidth: true
+ implicitHeight: 52
+ leftPadding: 14
+ rightPadding: 14
+ text: backend.hostname
+ placeholderText: "raveos-pc"
+ color: cTextWhite
+ placeholderTextColor: "#5b6270"
+ font.pixelSize: 15
+ background: Rectangle {
+ radius: 7
+ color: cInputBg
+ border.color: hostnameField.activeFocus ? cAccentGreen : cInputBorder
+ border.width: hostnameField.activeFocus ? 1.5 : 1
+ }
+ onTextEdited: backend.setHostname(text)
+ }
+
+ Text {
+ visible: backend.hostnameStatus !== ""
+ text: backend.hostnameStatus
+ color: cErrorRed
+ font.pixelSize: 12
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 6
+
+ Text {
+ text: RaveUsersModule.Translations.passwordLabel
+ color: "#c7ccd6"
+ font.pixelSize: 14
+ font.weight: Font.Medium
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 12
+
+ PasswordField {
+ Layout.fillWidth: true
+ reveal: showUserPassword
+ onRevealClicked: showUserPassword = !showUserPassword
+ placeholderText: RaveUsersModule.Translations.passwordPlaceholder
+ onTextEdited: backend.setPassword(text)
+ }
+
+ PasswordField {
+ Layout.fillWidth: true
+ reveal: showUserPassword
+ onRevealClicked: showUserPassword = !showUserPassword
+ placeholderText: RaveUsersModule.Translations.passwordConfirmLabel
+ onTextEdited: backend.setPasswordSecondary(text)
+ }
+ }
+
+ Text {
+ visible: backend.passwordStatus !== ""
+ text: backend.passwordStatus
+ color: cErrorRed
+ font.pixelSize: 12
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.topMargin: 2
+ Layout.bottomMargin: 2
+ height: 1
+ color: cCardBorder
+ opacity: 0.6
+ }
+
+ CheckBox {
+ id: reuseRootCheck
+ Layout.fillWidth: true
+ visible: backend.rootReuseVisible
+ text: RaveUsersModule.Translations.reuseRootLabel
+ checked: backend.reuseRootPassword
+ onToggled: backend.setReuseRootPassword(checked)
+
+ indicator: Rectangle {
+ implicitWidth: 20
+ implicitHeight: 20
+ x: reuseRootCheck.leftPadding
+ y: parent.height / 2 - height / 2
+ radius: 4
+ color: reuseRootCheck.checked ? cAccentGreen : cInputBg
+ border.color: reuseRootCheck.checked ? cAccentGreen : cInputBorder
+ border.width: 1
+
+ Text {
+ anchors.centerIn: parent
+ text: "\u2713"
+ font.pixelSize: 14
+ font.bold: true
+ color: "#0f1713"
+ visible: reuseRootCheck.checked
+ }
+ }
+
+ contentItem: Text {
+ text: reuseRootCheck.text
+ color: "#d7dbe2"
+ font.pixelSize: 14
+ verticalAlignment: Text.AlignVCenter
+ leftPadding: reuseRootCheck.indicator.width + 12
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 6
+ visible: !backend.reuseRootPassword
+
+ Text {
+ text: RaveUsersModule.Translations.rootPasswordLabel
+ color: "#c7ccd6"
+ font.pixelSize: 14
+ font.weight: Font.Medium
+ Layout.topMargin: 4
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 12
+
+ PasswordField {
+ Layout.fillWidth: true
+ reveal: showRootPassword
+ onRevealClicked: showRootPassword = !showRootPassword
+ placeholderText: RaveUsersModule.Translations.rootPasswordPlaceholder
+ onTextEdited: backend.setRootPasswordText(text)
+ }
+
+ PasswordField {
+ Layout.fillWidth: true
+ reveal: showRootPassword
+ onRevealClicked: showRootPassword = !showRootPassword
+ placeholderText: RaveUsersModule.Translations.rootPasswordConfirmLabel
+ onTextEdited: backend.setRootPasswordSecondaryText(text)
+ }
+ }
+
+ Text {
+ visible: backend.rootPasswordStatus !== ""
+ text: backend.rootPasswordStatus
+ color: cErrorRed
+ font.pixelSize: 12
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/raveos-calamares-theme/.DS_Store b/raveos-calamares-theme/.DS_Store
deleted file mode 100644
index b5bf013..0000000
--- a/raveos-calamares-theme/.DS_Store
+++ /dev/null
Binary files differ
diff --git a/raveos-calamares-theme/REVIEW.md b/raveos-calamares-theme/REVIEW.md
new file mode 100644
index 0000000..b629f31
--- /dev/null
+++ b/raveos-calamares-theme/REVIEW.md
@@ -0,0 +1,66 @@
+# raveos-calamares-theme — teljes átvizsgálás
+
+Dátum: 2026-09-18
+Állapot: a korábbi „Missing variables: 3,3,1,u,u,u” hiba már javítva (`42d8e63`).
+
+## 🔴 Érdemes javítani
+
+### 1. `shellprocess-final.conf` 34. sor — MESA/__GL env idézőjelezve
+A parancs a `/etc/environment`-be **idézőjelekkel együtt** írja a sorokat:
+
+```text
+"MESA_SHADER_CACHE_MAX_SIZE=12G"
+"__GL_SHADER_DISK_CACHE_SIZE=12884901888"
+```
+
+A systemd generátor valószínűleg leszedi a `"`-t, de más olvasók (pl. pam_env) nem
+feltétlenül, így a változó értéke `"12G"` lehet. Javasolt csere tiszta `printf`-re:
+
+```yaml
+- "printf 'MESA_SHADER_CACHE_MAX_SIZE=12G\\n__GL_SHADER_DISK_CACHE_SIZE=12884901888\\n' >> ${ROOT}/etc/environment"
+```
+
+### 2. `services-systemd.conf` bekapcsolja a gdm-et
+`- name: gdm, enable: true` — üti az SDDM-default fixet. A modul jelenleg
+nincs a sequence-ben (nem fut), de ha beillesztik, a gdm lesz az alap.
+Javasolt: `enable: false` vagy a gdm sor törlése.
+
+### 3. `shellprocess-loader.conf` → btrfs scriptek végén hard fail lehet
+- `calamares-normalize-subvolumes.sh` vége: `mount /var/cache/pacman/pkg`
+- `calamares-create-snapshots-subvol.sh` vége: `mount /.snapshots`
+
+Ezek nem `|| true`-sak és a loader konfban nincs `-` prefix; ha a mount elhasal,
+a telepítés a legvégén leáll. Javasolt: `|| true` a mount sorokra.
+
+## 🟡 Kisebb / kockázatos
+
+### 4. `bootloader.conf` kevert, nem szabványos kulcsok
+`bootloader: grub` mellett `efiBootLoader: systemd-boot`, plusz `kernel:
+linux-cachyos`, `initramfs: ...`, `grubInstall:` stb. A standard Calamares ezeket
+ignorálja, de forkolt modulnál a `systemd-boot` ütheti a grub-ot. Tisztázandó.
+
+### 5. `.DS_Store` fájlok a csomagban
+A PKGBUILD `cp -r "${startdir}/etc/."` a macOS `.DS_Store`-okat is beviszi a
+csomagba. Javasolt a `package()`-ben: `find "${pkgdir}" -name .DS_Store -delete`.
+
+### 6. `netinstall-drivers.yaml` elírás
+`exlusive: false` → helyesen `exclusive`. (Most nem fut, mert a netinstall
+ki van kommentezve a sequence-ből.)
+
+### 7. `shellprocess-branding.conf` — nincs ignore-failure prefix
+A live-on futó `pacman-key --recv-key` / `--lsign-key` sorokon nincs `-` prefix
+(ellentétben a `shellprocess-before`-ral). Ha a keyserver nem elérhető, az
+install leállhat. Javasolt `-` prefix ide is.
+
+### 8. `welcome.conf` internetCheckUrl
+`http://example.com` megbízhatatlan; érdemes valós, stabil URL-re cserélni.
+
+## 🟢 Rendben van
+
+- Mind a 7 `.sh` script `bash -n` tiszta; `raveos-chown-homes.sh` jó.
+- `settings.conf` sequence konzisztens (netinstall szándékosan kikapcsolva;
+ a csomagokat a desktopselect hozza).
+- `displaymanager.conf` SDDM default; `shellprocess-enableservices.conf`
+ SDDM enable + gdm disable — konzisztens.
+- users, locale, keyboard, grubcfg, mount, partition, preservefiles,
+ finished konfok rendben.
diff --git a/raveos-calamares-theme/etc/.DS_Store b/raveos-calamares-theme/etc/.DS_Store
deleted file mode 100644
index e3a60e9..0000000
--- a/raveos-calamares-theme/etc/.DS_Store
+++ /dev/null
Binary files differ
diff --git a/raveos-calamares-theme/etc/calamares/.DS_Store b/raveos-calamares-theme/etc/calamares/.DS_Store
deleted file mode 100644
index 0f5c1e6..0000000
--- a/raveos-calamares-theme/etc/calamares/.DS_Store
+++ /dev/null
Binary files differ
diff --git a/raveos-calamares-theme/etc/calamares/branding/.DS_Store b/raveos-calamares-theme/etc/calamares/branding/.DS_Store
deleted file mode 100644
index 54ecfe9..0000000
--- a/raveos-calamares-theme/etc/calamares/branding/.DS_Store
+++ /dev/null
Binary files differ
diff --git a/raveos-calamares-theme/etc/calamares/branding/raveos/.DS_Store b/raveos-calamares-theme/etc/calamares/branding/raveos/.DS_Store
deleted file mode 100644
index f2e5f54..0000000
--- a/raveos-calamares-theme/etc/calamares/branding/raveos/.DS_Store
+++ /dev/null
Binary files differ
diff --git a/raveos-calamares-theme/etc/calamares/settings.conf b/raveos-calamares-theme/etc/calamares/settings.conf
index f40f8ab..9780d3c 100755
--- a/raveos-calamares-theme/etc/calamares/settings.conf
+++ b/raveos-calamares-theme/etc/calamares/settings.conf
@@ -154,7 +154,7 @@ sequence:
- locale
- keyboard
- partition
- - users
+ - raveusers
- desktopselect
# - netinstall@apps
# - tracking
@@ -176,7 +176,7 @@ sequence:
- initcpio
- bootloader
- removeuser
- - users
+ - raveusers
- shellprocess@copybrave
- networkcfg
- hwclock
diff --git a/raveos-updater/PKGBUILD b/raveos-updater/PKGBUILD
new file mode 100644
index 0000000..6bb6622
--- /dev/null
+++ b/raveos-updater/PKGBUILD
@@ -0,0 +1,28 @@
+# Maintainer: nippy <nippy@tuta.io>
+
+pkgname=raveos-updater
+pkgver=1.0.0
+pkgrel=1
+pkgdesc="RaveOS update checker background service and tray indicator"
+arch=('any')
+url="https://git.rp1.hu/RaveOS"
+license=('GPL')
+depends=('python' 'python-pyqt6' 'pacman')
+install="${pkgname}.install"
+source=()
+sha256sums=()
+
+package() {
+ install -Dm755 "${startdir}/check.py" \
+ "${pkgdir}/usr/lib/raveos-updater/check.py"
+ install -Dm755 "${startdir}/raveos-updater" \
+ "${pkgdir}/usr/bin/raveos-updater"
+ install -Dm644 "${startdir}/raveos-updater.service" \
+ "${pkgdir}/usr/lib/systemd/system/raveos-updater.service"
+ install -Dm644 "${startdir}/raveos-updater.timer" \
+ "${pkgdir}/usr/lib/systemd/system/raveos-updater.timer"
+ install -Dm644 "${startdir}/raveos-updater.hook" \
+ "${pkgdir}/usr/share/libalpm/hooks/raveos-updater.hook"
+ install -Dm644 "${startdir}/raveos-updater.desktop" \
+ "${pkgdir}/etc/xdg/autostart/raveos-updater.desktop"
+}
diff --git a/raveos-updater/check.py b/raveos-updater/check.py
new file mode 100644
index 0000000..79a0ba3
--- /dev/null
+++ b/raveos-updater/check.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""RaveOS frissítés-ellenőrző.
+
+A raveos-core-repo szinkron adatbázisából kiolvassa a RaveOS saját
+csomagjainak legfrissebb verzióját, összeveti a telepített verziókkal,
+és a /var/lib/raveos-updater/status.json fájlba írja az eredményt.
+
+Használat:
+ check.py # pacman -Sy után ellenőriz (root; a systemd timer hívja)
+ check.py --no-sync # nem szinkronizál, a meglévő DB-t használja (pacman hook)
+"""
+
+import json
+import os
+import subprocess
+import sys
+from datetime import datetime
+
+REPO = "raveos-core-repo"
+STATUS_DIR = "/var/lib/raveos-updater"
+STATUS_FILE = os.path.join(STATUS_DIR, "status.json")
+
+
+def run(args):
+ return subprocess.run(args, capture_output=True, text=True)
+
+
+def main():
+ sync = "--no-sync" not in sys.argv[1:]
+ error = None
+
+ if sync:
+ r = run(["pacman", "-Sy", "--noconfirm"])
+ if r.returncode != 0:
+ error = "sync failed"
+
+ installed = {}
+ repo = {}
+
+ try:
+ for line in run(["pacman", "-Q"]).stdout.splitlines():
+ name, _, version = line.partition(" ")
+ if version:
+ installed[name] = version
+
+ for line in run(["pacman", "-Sl", REPO]).stdout.splitlines():
+ parts = line.split()
+ if len(parts) >= 3:
+ repo[parts[1]] = parts[2]
+ except FileNotFoundError:
+ error = "pacman not found"
+
+ updates = []
+ for name, newver in sorted(repo.items()):
+ oldver = installed.get(name)
+ if oldver is None:
+ continue
+ r = run(["vercmp", newver, oldver])
+ try:
+ newer = int(r.stdout.strip()) > 0
+ except (ValueError, AttributeError):
+ continue
+ if newer:
+ updates.append({"name": name, "old": oldver, "new": newver})
+
+ status = {
+ "count": len(updates),
+ "checked": datetime.now().astimezone().isoformat(timespec="seconds"),
+ "updates": updates,
+ }
+ if error:
+ status["error"] = error
+
+ os.makedirs(STATUS_DIR, exist_ok=True)
+ tmp = STATUS_FILE + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(status, f, ensure_ascii=False, indent=2)
+ os.replace(tmp, STATUS_FILE)
+ os.chmod(STATUS_FILE, 0o644)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/raveos-updater/raveos-updater b/raveos-updater/raveos-updater
new file mode 100644
index 0000000..769da64
--- /dev/null
+++ b/raveos-updater/raveos-updater
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""RaveOS Update Indicator — tálca-jelző.
+
+Önálló (DE-független) tálca-alkalmazás. A /var/lib/raveos-updater/status.json
+fájlt olvassa, amit a háttérszolgáltatás (check.py + systemd timer) állít elő.
+A jobb felső tálcán egy ikon mutatja, ha van elérhető RaveOS frissítés.
+
+Első verzió: GNOME (Qt6 SNI tálca az AppIndicator kiegészítővel).
+Később Plasma/Hyprland/COSMIC is ugyanezt a status.json-t használja.
+"""
+
+import json
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+
+from PyQt6.QtCore import Qt, QTimer
+from PyQt6.QtGui import QAction, QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap
+from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
+
+STATUS_FILE = "/var/lib/raveos-updater/status.json"
+CHECK_CMD = "/usr/lib/raveos-updater/check.py"
+APP_NAME = "RaveOS Update Indicator"
+
+
+def read_status():
+ try:
+ with open(STATUS_FILE, encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ return None
+
+
+def make_icon(count):
+ pm = QPixmap(64, 64)
+ pm.fill(Qt.GlobalColor.transparent)
+ p = QPainter(pm)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ if count:
+ p.setBrush(QBrush(QColor("#23e06b")))
+ p.setPen(QPen(QColor("#12833c"), 3))
+ else:
+ p.setBrush(QBrush(QColor("#5a5a5a")))
+ p.setPen(QPen(QColor("#3a3a3a"), 3))
+ p.drawEllipse(6, 6, 52, 52)
+
+ p.setPen(QColor("#ffffff"))
+ if count:
+ font = QFont()
+ font.setBold(True)
+ font.setPixelSize(30)
+ p.setFont(font)
+ p.drawText(pm.rect(), Qt.AlignmentFlag.AlignCenter, str(count) if count < 100 else "99")
+ else:
+ pen = QPen(QColor("#ffffff"), 7)
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
+ p.setPen(pen)
+ p.drawLine(18, 33, 28, 43)
+ p.drawLine(28, 43, 47, 20)
+ p.end()
+ return QIcon(pm)
+
+
+def spawn_terminal(cmd):
+ candidates = [
+ ["kgx", "-e", "bash", "-c", cmd],
+ ["gnome-terminal", "--", "bash", "-c", cmd],
+ ["konsole", "-e", "bash", "-c", cmd],
+ ["xterm", "-e", "bash", "-c", cmd],
+ ]
+ for c in candidates:
+ if shutil.which(c[0]):
+ subprocess.Popen(c, start_new_session=True)
+ return True
+ return False
+
+
+class Updater:
+ def __init__(self, app):
+ self.app = app
+ self.tray = QSystemTrayIcon()
+ self.tray.setToolTip(APP_NAME)
+ self.menu = QMenu()
+ self.tray.setContextMenu(self.menu)
+ self.refresh()
+ self.tray.show()
+ # Qt6 SNI: a tálca-ikon regisztrációjához legalább egy esemény-hurok
+ # iteráció kell, különben GNOME-on nem jelenik meg az ikon.
+ self.app.processEvents()
+
+ self.timer = QTimer()
+ self.timer.timeout.connect(self.refresh)
+ self.timer.start(60_000)
+
+ def refresh(self):
+ st = read_status()
+ self.menu.clear()
+
+ if st is None:
+ self.tray.setIcon(make_icon(0))
+ self._add("RaveOS állapot ismeretlen", enabled=False)
+ self._add("(az ellenőrzés még nem futott le)", enabled=False)
+ elif st.get("error"):
+ self.tray.setIcon(make_icon(0))
+ self._add("RaveOS ellenőrzés sikertelen", enabled=False)
+ self._add(st["error"], enabled=False)
+ else:
+ count = st.get("count", 0)
+ self.tray.setIcon(make_icon(count))
+ if count:
+ self._add(f"{count} RaveOS frissítés elérhető", enabled=False)
+ self.tray.setToolTip(f"{APP_NAME}: {count} frissítés elérhető")
+ self.menu.addSeparator()
+ for u in st.get("updates", []):
+ self._add(f"{u['name']} {u['old']} → {u['new']}", enabled=False)
+ else:
+ self._add("RaveOS naprakész", enabled=False)
+ self.tray.setToolTip(f"{APP_NAME}: naprakész")
+ checked = st.get("checked", "")
+ if checked:
+ self._add("Ellenőrizve: " + checked.replace("T", " ")[:16], enabled=False)
+
+ self.menu.addSeparator()
+ self._add("Frissítés most", self._update_now)
+ self._add("Ellenőrzés most", self._check_now)
+ self._add("Kilépés", self.app.quit)
+
+ def _add(self, text, callback=None, enabled=True):
+ act = QAction(text, self.menu)
+ act.setEnabled(enabled)
+ if callback:
+ act.triggered.connect(callback)
+ self.menu.addAction(act)
+ return act
+
+ def _update_now(self):
+ spawn_terminal("rave upgrade; exec bash")
+
+ def _check_now(self):
+ if shutil.which("pkexec"):
+ subprocess.Popen(["pkexec", CHECK_CMD], start_new_session=True)
+ else:
+ subprocess.Popen(["sudo", CHECK_CMD], start_new_session=True)
+
+
+def main():
+ app = QApplication(sys.argv)
+ app.setApplicationName(APP_NAME)
+ app.setQuitOnLastWindowClosed(False)
+
+ if not QSystemTrayIcon.isSystemTrayAvailable():
+ print("RaveOS Update Indicator: nincs elérhető tálca (SNI) a jelenlegi környezetben.",
+ file=sys.stderr)
+
+ Updater(app)
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/raveos-updater/raveos-updater.desktop b/raveos-updater/raveos-updater.desktop
new file mode 100644
index 0000000..501f8c7
--- /dev/null
+++ b/raveos-updater/raveos-updater.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Type=Application
+Name=RaveOS Update Indicator
+Name[hu]=RaveOS Frissítésjelző
+Comment=Shows when RaveOS package updates are available
+Comment[hu]=Jelzi, ha elérhető RaveOS frissítés
+Exec=/usr/bin/raveos-updater
+Icon=raveos-updater
+Terminal=false
+NoDisplay=true
+X-GNOME-Autostart-enabled=true
+StartupNotify=false
diff --git a/raveos-updater/raveos-updater.hook b/raveos-updater/raveos-updater.hook
new file mode 100644
index 0000000..ebc1583
--- /dev/null
+++ b/raveos-updater/raveos-updater.hook
@@ -0,0 +1,11 @@
+[Trigger]
+Operation = Install
+Operation = Upgrade
+Operation = Remove
+Type = Package
+Target = *
+
+[Action]
+Description = Refreshing RaveOS update status
+When = PostTransaction
+Exec = /usr/lib/raveos-updater/check.py --no-sync
diff --git a/raveos-updater/raveos-updater.install b/raveos-updater/raveos-updater.install
new file mode 100644
index 0000000..f2993fd
--- /dev/null
+++ b/raveos-updater/raveos-updater.install
@@ -0,0 +1,20 @@
+_enable() {
+ if command -v systemctl >/dev/null 2>&1; then
+ systemctl daemon-reload >/dev/null 2>&1 || true
+ systemctl enable --now raveos-updater.timer >/dev/null 2>&1 || true
+ fi
+}
+
+post_install() {
+ _enable
+}
+
+post_upgrade() {
+ _enable
+}
+
+pre_remove() {
+ if command -v systemctl >/dev/null 2>&1; then
+ systemctl disable --now raveos-updater.timer >/dev/null 2>&1 || true
+ fi
+}
diff --git a/raveos-updater/raveos-updater.service b/raveos-updater/raveos-updater.service
new file mode 100644
index 0000000..118c71e
--- /dev/null
+++ b/raveos-updater/raveos-updater.service
@@ -0,0 +1,6 @@
+[Unit]
+Description=RaveOS update check service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/lib/raveos-updater/check.py
diff --git a/raveos-updater/raveos-updater.timer b/raveos-updater/raveos-updater.timer
new file mode 100644
index 0000000..9ab66a9
--- /dev/null
+++ b/raveos-updater/raveos-updater.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=RaveOS update check timer
+
+[Timer]
+OnBootSec=5min
+OnUnitActiveSec=2h
+Persistent=true
+
+[Install]
+WantedBy=timers.target