Line data Source code
1 : /*
2 : * SPDX-License-Identifier: GPL-3.0-only
3 : * MuseScore-CLA-applies
4 : *
5 : * MuseScore Studio
6 : * Music Composition & Notation
7 : *
8 : * Copyright (C) 2026 MuseScore Limited and others
9 : *
10 : * This program is free software: you can redistribute it and/or modify
11 : * it under the terms of the GNU General Public License version 3 as
12 : * published by the Free Software Foundation.
13 : *
14 : * This program is distributed in the hope that it will be useful,
15 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 : * GNU General Public License for more details.
18 : *
19 : * You should have received a copy of the GNU General Public License
20 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
21 : */
22 : #include "linuxupdateinstaller.h"
23 :
24 : #include <cstring>
25 : #include <unistd.h>
26 :
27 : #include <QByteArray>
28 : #include <QCoreApplication>
29 : #include <QFile>
30 : #include <QFileInfo>
31 : #include <QProcess>
32 : #include <QStringList>
33 :
34 : #include "../../../updateerrors.h"
35 :
36 : #include "log.h"
37 :
38 : using namespace muse;
39 : using namespace muse::update;
40 :
41 : static const QString HELPER_NAME("museupdater");
42 :
43 : namespace {
44 : //! An AppImage is an ELF executable with a marker in the bytes the ELF header
45 : //! reserves for the OS ABI: "AI" followed by the AppImage format version.
46 : //! Only type 2 is produced nowadays, and it is what we publish.
47 : constexpr int APPIMAGE_HEADER_SIZE = 11;
48 : constexpr char APPIMAGE_TYPE_2 = 0x02;
49 :
50 0 : bool isAppImageFile(const QString& path)
51 : {
52 0 : QFile file(path);
53 0 : if (!file.open(QIODevice::ReadOnly)) {
54 0 : LOGE() << "failed to open for reading: " << path;
55 0 : return false;
56 : }
57 :
58 0 : char header[APPIMAGE_HEADER_SIZE] = { 0 };
59 0 : if (file.read(header, APPIMAGE_HEADER_SIZE) != APPIMAGE_HEADER_SIZE) {
60 0 : LOGE() << "file is too small to be an AppImage: " << path;
61 0 : return false;
62 : }
63 :
64 : static const char ELF_MAGIC[] = { 0x7f, 'E', 'L', 'F' };
65 0 : if (std::memcmp(header, ELF_MAGIC, sizeof(ELF_MAGIC)) != 0) {
66 0 : LOGE() << "not an ELF file: " << path;
67 0 : return false;
68 : }
69 :
70 0 : if (header[8] != 'A' || header[9] != 'I' || header[10] != APPIMAGE_TYPE_2) {
71 0 : LOGE() << "not a type 2 AppImage: " << path;
72 0 : return false;
73 : }
74 :
75 0 : return true;
76 0 : }
77 :
78 0 : bool isWritable(const QString& path)
79 : {
80 0 : return ::access(path.toUtf8().constData(), W_OK) == 0;
81 : }
82 : }
83 :
84 0 : io::path_t LinuxUpdateInstaller::currentAppImagePath() const
85 : {
86 : //! NOTE: Set by the AppImage runtime. Absent for every other way of running
87 : //! the application, none of which can be updated by replacing one file.
88 0 : const QByteArray appImage = qgetenv("APPIMAGE");
89 0 : if (appImage.isEmpty()) {
90 0 : return {};
91 : }
92 :
93 0 : const QFileInfo info(QFile::decodeName(appImage));
94 0 : if (!info.exists() || !info.isFile()) {
95 0 : return {};
96 : }
97 :
98 0 : return io::path_t(info.canonicalFilePath());
99 0 : }
100 :
101 0 : io::path_t LinuxUpdateInstaller::helperPath() const
102 : {
103 0 : return io::path_t(QCoreApplication::applicationDirPath() + "/" + HELPER_NAME);
104 : }
105 :
106 0 : bool LinuxUpdateInstaller::isInPlaceUpdateSupported() const
107 : {
108 0 : const QString appImage = currentAppImagePath().toQString();
109 0 : if (appImage.isEmpty()) {
110 0 : return false;
111 : }
112 :
113 0 : if (!QFileInfo::exists(helperPath().toQString())) {
114 0 : return false;
115 : }
116 :
117 : //! NOTE: Replacing the AppImage creates a new directory entry, so the
118 : //! containing directory has to be writable too - a writable file inside a
119 : //! read-only directory (a system-wide install) is not enough.
120 0 : if (!isWritable(appImage) || !isWritable(QFileInfo(appImage).absolutePath())) {
121 0 : return false;
122 : }
123 :
124 0 : return true;
125 0 : }
126 :
127 0 : RetVal<muse::io::path_t> LinuxUpdateInstaller::prepareUpdate(const muse::io::path_t& packagePath)
128 : {
129 0 : const QString package = packagePath.toQString();
130 0 : if (!QFileInfo::exists(package)) {
131 0 : LOGE() << "update package does not exist: " << package;
132 0 : return RetVal<muse::io::path_t>(make_ret(Err::UnknownError));
133 : }
134 :
135 : // 1. The downloaded package replaces the running file as-is, so make sure it
136 : // really is an AppImage before letting the helper move it into place.
137 0 : if (!isAppImageFile(package)) {
138 0 : LOGE() << "update package is not a valid AppImage: " << package;
139 0 : return RetVal<muse::io::path_t>(make_ret(Err::UnknownError));
140 : }
141 :
142 : // 2. The download has no executable bit; the swapped-in file must be
143 : // runnable.
144 0 : const QFile::Permissions permissions
145 : = QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
146 : | QFile::ReadGroup | QFile::ExeGroup
147 : | QFile::ReadOther | QFile::ExeOther;
148 :
149 0 : if (!QFile::setPermissions(package, permissions)) {
150 0 : LOGE() << "failed to make update package executable: " << package;
151 0 : return RetVal<muse::io::path_t>(make_ret(Err::UnknownError));
152 : }
153 :
154 0 : return RetVal<muse::io::path_t>::make_ok(packagePath);
155 0 : }
156 :
157 0 : Ret LinuxUpdateInstaller::finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi&)
158 : {
159 0 : const QString package = preparedPath.toQString();
160 0 : if (!QFileInfo::exists(package)) {
161 0 : LOGE() << "prepared update does not exist: " << package;
162 0 : return make_ret(Err::UnknownError);
163 : }
164 :
165 0 : const QString appImagePath = currentAppImagePath().toQString();
166 0 : if (appImagePath.isEmpty()) {
167 0 : LOGE() << "not running from an AppImage, cannot update in place";
168 0 : return make_ret(Ret::Code::NotSupported);
169 : }
170 :
171 0 : const QFile::Permissions permissions
172 : = QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner
173 : | QFile::ReadGroup | QFile::ExeGroup
174 : | QFile::ReadOther | QFile::ExeOther;
175 :
176 : // 1. Copy the helper out of the AppImage. Its mount point disappears as soon
177 : // as this process exits, which is exactly when the helper starts working.
178 0 : fileSystem()->makePath(configuration()->updateDataPath());
179 :
180 0 : const QString helperRun = configuration()->updateDataPath().toQString() + "/" + HELPER_NAME;
181 0 : QFile::remove(helperRun);
182 0 : if (!QFile::copy(helperPath().toQString(), helperRun)) {
183 0 : LOGE() << "failed to copy helper to " << helperRun;
184 0 : return make_ret(Err::UnknownError);
185 : }
186 0 : QFile::setPermissions(helperRun, permissions);
187 :
188 : // 2. Spawn the detached helper. It waits for us to quit, replaces the
189 : // AppImage and relaunches it.
190 0 : const QString logPath = configuration()->helperLogPath().toQString();
191 : const QStringList args = {
192 : "--wait-pid", QString::number(QCoreApplication::applicationPid()),
193 : "--src", package,
194 : "--dst", appImagePath,
195 : "--relaunch", appImagePath,
196 : "--log", logPath
197 0 : };
198 :
199 0 : if (!QProcess::startDetached(helperRun, args)) {
200 0 : LOGE() << "failed to start update helper";
201 0 : return make_ret(Err::UnknownError);
202 : }
203 :
204 0 : LOGI() << "update helper started, will replace " << appImagePath << " after quit";
205 0 : return make_ok();
206 0 : }
|