diff --git a/src/Style.qml b/src/Style.qml index 7804b5718..2cb673177 100644 --- a/src/Style.qml +++ b/src/Style.qml @@ -89,6 +89,7 @@ Item { /// the check-boxes/radio-buttons have labels that might be disabled readonly property color formLabelColor: "black" readonly property color formLabelErrorColor: "red" + readonly property color formLabelWarningColor: "#e8a000" readonly property color formLabelDisabledColor: "grey" // Active color for radio buttons, checkboxes, and switches readonly property color formControlActiveColor: "#1955AE" diff --git a/src/cli.cpp b/src/cli.cpp index 4108e0295..38c521849 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -58,6 +58,7 @@ int Cli::run() {"quiet", "Only write to console on error"}, {"log-file", "Log output to file (for debugging)", "path", ""}, {"secure-boot-key", "Path to RSA private key (PEM format) for secure boot signing", "key-file", ""}, + {"non-root", "Allow running without elevated privileges"} }); parser.addPositionalArgument("src", "Image file/URL"); @@ -65,7 +66,7 @@ int Cli::run() parser.process(*_app); // Check for elevated privileges on platforms that require them (Linux/Windows) - if (!PlatformQuirks::hasElevatedPrivileges()) + if (!PlatformQuirks::hasElevatedPrivileges() && !parser.isSet("non-root")) { // Common error message const char* commonMsg = "Writing to storage devices requires elevated privileges."; diff --git a/src/drivelist/drivelist.h b/src/drivelist/drivelist.h index 3de7ba200..0a7a82505 100644 --- a/src/drivelist/drivelist.h +++ b/src/drivelist/drivelist.h @@ -210,6 +210,7 @@ struct DeviceDescriptor { bool isSCSI = false; ///< Connected via SCSI/SAS bool isUSB = false; ///< Connected via USB bool isUAS = false; ///< Connected via USB Attached SCSI + bool isWritableByUser = true; ///< Current user has write access to the device node (Linux only; always true on other platforms) // Null indicators (for optional fields) bool busVersionNull = true; diff --git a/src/drivelist/drivelist_linux.cpp b/src/drivelist/drivelist_linux.cpp index 7df92d419..5ad936bbf 100644 --- a/src/drivelist/drivelist_linux.cpp +++ b/src/drivelist/drivelist_linux.cpp @@ -14,6 +14,7 @@ #include "embedded_config.h" #include +#include #include #include #include @@ -219,6 +220,13 @@ std::optional parseBlockDevice(const QJsonObject& bdev, bool e // Non-embedded mode: keep NVMe marked as system (filtered out) } + // Check whether the current effective user can write to the device node. + // This is relevant when running with --non-root: the application is started + // directly as a regular user (no sudo, no setuid), so the real UID equals + // the effective UID and ::access() gives the correct result. Root users will + // always get isWritableByUser = true since they own all device nodes. + device.isWritableByUser = (::access(name.toLocal8Bit().constData(), W_OK) == 0); + return device; } diff --git a/src/drivelistitem.cpp b/src/drivelistitem.cpp index be1a8649e..8308c5a07 100644 --- a/src/drivelistitem.cpp +++ b/src/drivelistitem.cpp @@ -6,10 +6,10 @@ #include "drivelistitem.h" DriveListItem::DriveListItem(QString device, QString description, quint64 size, bool isUsb, bool isScsi, bool readOnly, bool isSystem, QStringList mountpoints, QStringList childDevices, - bool isRpiboot, + bool isRpiboot, bool isWritableByUser, QObject *parent) : QObject(parent), _device(device), _description(description), _mountpoints(mountpoints), _childDevices(childDevices), _size(size), _isUsb(isUsb), _isScsi(isScsi), _isReadOnly(readOnly), _isSystem(isSystem) - , _isRpiboot(isRpiboot) + , _isRpiboot(isRpiboot), _isWritableByUser(isWritableByUser) { } diff --git a/src/drivelistitem.h b/src/drivelistitem.h index 6898dfd30..224af5b0b 100644 --- a/src/drivelistitem.h +++ b/src/drivelistitem.h @@ -15,6 +15,7 @@ class DriveListItem : public QObject public: explicit DriveListItem(QString device, QString description, quint64 size, bool isUsb = false, bool isScsi = false, bool readOnly = false, bool isSystem = false, QStringList mountpoints = QStringList(), QStringList childDevices = QStringList(), bool isRpiboot = false, + bool isWritableByUser = true, QObject *parent = nullptr); Q_PROPERTY(QString device MEMBER _device CONSTANT) @@ -27,6 +28,7 @@ class DriveListItem : public QObject Q_PROPERTY(bool isReadOnly MEMBER _isReadOnly CONSTANT) Q_PROPERTY(bool isSystem MEMBER _isSystem CONSTANT) Q_PROPERTY(bool isRpiboot MEMBER _isRpiboot CONSTANT) + Q_PROPERTY(bool isWritableByUser MEMBER _isWritableByUser CONSTANT) Q_INVOKABLE int sizeInGb(); signals: @@ -44,6 +46,7 @@ public slots: bool _isReadOnly; bool _isSystem; bool _isRpiboot; + bool _isWritableByUser; }; #endif // DRIVELISTITEM_H diff --git a/src/drivelistmodel.cpp b/src/drivelistmodel.cpp index b627b4c77..4b430e32d 100644 --- a/src/drivelistmodel.cpp +++ b/src/drivelistmodel.cpp @@ -22,7 +22,8 @@ DriveListModel::DriveListModel(QObject *parent) {isSystemRole, "isSystem"}, {mountpointsRole, "mountpoints"}, {childDevicesRole, "childDevices"}, - {isRpibootRole, "isRpiboot"} + {isRpibootRole, "isRpiboot"}, + {isWritableByUserRole, "isWritableByUser"} }; // Enumerate drives in separate thread, but process results in UI thread @@ -102,6 +103,7 @@ void DriveListModel::processDriveList(std::vector l QStringList mountpoints; QStringList childDevices; bool isRpiboot = false; + bool isWritableByUser = true; }; QList drivesToAdd; @@ -174,6 +176,7 @@ void DriveListModel::processDriveList(std::vector l info.mountpoints = mountpoints; info.childDevices = childDevices; info.isRpiboot = isRpibootDevice; + info.isWritableByUser = i.isWritableByUser; drivesToAdd.append(info); } } @@ -221,7 +224,7 @@ void DriveListModel::processDriveList(std::vector l info.device, info.description, info.size, info.isUSB, info.isScsi, info.isReadOnly, info.isSystem, info.mountpoints, info.childDevices, - info.isRpiboot, + info.isRpiboot, info.isWritableByUser, this); endInsertRows(); diff --git a/src/drivelistmodel.h b/src/drivelistmodel.h index e630b6c8c..d36a22549 100644 --- a/src/drivelistmodel.h +++ b/src/drivelistmodel.h @@ -79,7 +79,7 @@ class DriveListModel : public QAbstractListModel enum driveListRoles { deviceRole = Qt::UserRole + 1, descriptionRole, sizeRole, isUsbRole, isScsiRole, isReadOnlyRole, isSystemRole, mountpointsRole, childDevicesRole, - isRpibootRole + isRpibootRole, isWritableByUserRole }; signals: diff --git a/src/main.cpp b/src/main.cpp index e285e812f..1cc7bce97 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -343,7 +343,8 @@ int main(int argc, char *argv[]) {"disable-telemetry", "Disable telemetry (persist setting)"}, {"enable-telemetry", "Use default telemetry setting (clear override)"}, {"qml-file-dialogs", "Force use of QML file dialogs instead of native dialogs"}, - {"enable-secure-boot", "Force enable secure boot customization step regardless of OS capabilities"} + {"enable-secure-boot", "Force enable secure boot customization step regardless of OS capabilities"}, + {"non-root", "Allow running without elevated privileges"} }); // Accept rpi-imager:// callback URLs as positional argument (used by callback relay on Windows) @@ -752,7 +753,7 @@ int main(int argc, char *argv[]) }); // Emit permission warning signal after UI is loaded so dialog can be shown - if (hasPermissionIssue) + if (hasPermissionIssue && !parser.isSet("non-root")) { // Common message parts to reduce translation effort QString header = QObject::tr("Raspberry Pi Imager requires elevated privileges to write to storage devices."); diff --git a/src/wizard/StorageSelectionStep.qml b/src/wizard/StorageSelectionStep.qml index ae7d2f928..c6892c12f 100644 --- a/src/wizard/StorageSelectionStep.qml +++ b/src/wizard/StorageSelectionStep.qml @@ -65,6 +65,7 @@ WizardStepBase { property bool hasValidStorageOptions: false property bool hasAnyDevices: false property bool hasOnlyReadOnlyDevices: false + property bool hasUnwritableDevices: false property string enumerationErrorMessage: "" Component.onCompleted: { @@ -149,7 +150,42 @@ WizardStepBase { Accessible.role: Accessible.AlertMessage Accessible.name: qsTr("Error: Could not list storage devices. %1").arg(root.enumerationErrorMessage) } - + + // Info banner shown when some devices are not writable by the current user + Rectangle { + id: unwritableBanner + Layout.fillWidth: true + Layout.preferredHeight: visible ? unwritableBannerContent.implicitHeight + Style.spacingMedium * 2 : 0 + visible: root.hasUnwritableDevices + color: Style.formLabelWarningColor + opacity: 0.9 + + RowLayout { + id: unwritableBannerContent + anchors.fill: parent + anchors.margins: Style.spacingMedium + spacing: Style.spacingSmall + + Text { + text: "⚠" + font.pointSize: Style.fontSizeFormLabel + color: "white" + } + + Text { + Layout.fillWidth: true + text: qsTr("Some storage devices cannot be written to by the current user. Run with elevated privileges (e.g. sudo) to access them.") + font.pointSize: Style.fontSizeDescription + font.family: Style.fontFamily + color: "white" + wrapMode: Text.WordWrap + } + } + + Accessible.role: Accessible.AlertMessage + Accessible.name: qsTr("Warning: Some storage devices require elevated privileges. Run with sudo to access them.") + } + // Storage device list fills available space SelectionListView { id: dstlist @@ -335,16 +371,17 @@ WizardStepBase { required property bool isScsi required property bool isReadOnly required property bool isSystem + required property bool isWritableByUser required property var mountpoints required property QtObject modelData property bool isRpiboot: modelData && typeof modelData.isRpiboot !== "undefined" ? modelData.isRpiboot : false readonly property bool shouldHide: isSystem && filterSystemDrives.checked - readonly property bool unselectable: isReadOnly && !isRpiboot - + readonly property bool unselectable: (isReadOnly || !isWritableByUser) && !isRpiboot + // Accessibility properties Accessible.role: Accessible.ListItem - Accessible.name: dstitem.description + ". " + imageWriter.formatSize(parseFloat(dstitem.size)) + (dstitem.mountpoints.length > 0 ? ". " + qsTr("Mounted as %1").arg(dstitem.mountpoints.join(", ")) : "") + (dstitem.unselectable ? ". " + qsTr("Read-only") : "") + Accessible.name: dstitem.description + ". " + imageWriter.formatSize(parseFloat(dstitem.size)) + (dstitem.mountpoints.length > 0 ? ". " + qsTr("Mounted as %1").arg(dstitem.mountpoints.join(", ")) : "") + (dstitem.isReadOnly && !dstitem.isRpiboot ? ". " + qsTr("Read-only") : (!dstitem.isWritableByUser && !dstitem.isRpiboot ? ". " + qsTr("No write permission") : "")) Accessible.focusable: true Accessible.ignored: false @@ -460,9 +497,9 @@ WizardStepBase { } } - // Read-only indicator + // Read-only / no-permission indicator Text { - text: qsTr("Read-only") + text: !dstitem.isWritableByUser ? qsTr("No write permission") : qsTr("Read-only") font.pointSize: Style.fontSizeDescription font.family: Style.fontFamily color: Style.formLabelErrorColor @@ -524,28 +561,30 @@ WizardStepBase { var isReadOnlyRole = 0x106 var isSystemRole = 0x107 var mountpointsRole = 0x108 - + var isWritableByUserRole = 0x10b + var isReadOnly = model.data(modelIndex, isReadOnlyRole) - if (isReadOnly) { - return // Can't select read-only devices + var isWritableByUser = model.data(modelIndex, isWritableByUserRole) + if (isReadOnly || isWritableByUser === false) { + return // Can't select read-only or unwritable devices } - + var isSystem = model.data(modelIndex, isSystemRole) var device = model.data(modelIndex, deviceRole) var description = model.data(modelIndex, descriptionRole) var size = model.data(modelIndex, sizeRole) var mountpoints = model.data(modelIndex, mountpointsRole) || [] - + // Create a mock item object with the required properties var mockItem = { - unselectable: isReadOnly, + unselectable: isReadOnly || isWritableByUser === false, isSystem: isSystem, device: device, description: description, size: size, mountpoints: mountpoints } - + root.selectDstItem(mockItem) } @@ -558,14 +597,16 @@ WizardStepBase { var isReadOnlyRole = 0x106 var isSystemRole = 0x107 - + var isWritableByUserRole = 0x10b + var idx = model.index(index, 0) var isReadOnly = model.data(idx, isReadOnlyRole) + var isWritableByUser = model.data(idx, isWritableByUserRole) var isSystem = model.data(idx, isSystemRole) - - // Item is selectable if it's not read-only and either not a system drive or filter is off + + // Item is selectable if it's not read-only, writable by user, and not hidden var shouldHide = isSystem && filterSystemDrives.checked - return !isReadOnly && !shouldHide + return !isReadOnly && isWritableByUser !== false && !shouldHide } // Check if there are any selectable items in the list @@ -588,7 +629,7 @@ WizardStepBase { var model = root.imageWriter.getDriveList() root.hasValidStorageOptions = hasSelectableItems() root.hasAnyDevices = model && model.rowCount() > 0 - + // Check if we only have read-only devices if (root.hasAnyDevices && !root.hasValidStorageOptions) { var isReadOnlyRole = 0x106 @@ -605,6 +646,26 @@ WizardStepBase { } else { root.hasOnlyReadOnlyDevices = false } + + // Check whether any visible device is unwritable by the current user + var isWritableByUserRole = 0x10b + var isSystemRole = 0x107 + var foundUnwritable = false + if (root.hasAnyDevices) { + for (var j = 0; j < model.rowCount(); j++) { + var jdx = model.index(j, 0) + var isSystem = model.data(jdx, isSystemRole) + // Only consider visible (non-hidden) devices + if (isSystem && filterSystemDrives.checked) + continue + var writable = model.data(jdx, isWritableByUserRole) + if (writable === false) { + foundUnwritable = true + break + } + } + } + root.hasUnwritableDevices = foundUnwritable } // Get the storage status message for accessibility