mirror of
https://github.com/cmclark00/retro-imager.git
synced 2025-05-17 23:45:21 +01:00
Switch to using FAT classes for advanced settings
- No longer relies on operating system for mounting FAT partition when applying 'advanced settings' - change '__attribute__ ((packed))' to '#pragma pack()' as the mingw version we are using for Windows has a bug with the former
This commit is contained in:
parent
ebc6edc0c3
commit
5fa3fbe8dc
5 changed files with 142 additions and 321 deletions
12
debian/changelog
vendored
12
debian/changelog
vendored
|
@ -1,3 +1,15 @@
|
|||
rpi-imager (1.7.4) unstable; urgency=medium
|
||||
|
||||
* Advanced settings: fix escaping single quotes
|
||||
* Advanced settings: default to using username of logged-in user
|
||||
* Now uses a different method to edit files on the FAT partition
|
||||
to apply advanced settings. Imager now understands the FAT16/FAT32
|
||||
file system format and can edit files by itself using the raw
|
||||
disk device, without having to rely on the operating system
|
||||
to mount the partition first.
|
||||
|
||||
-- Floris Bos <bos@je-eigen-domein.nl> Mon, 14 Nov 2022 21:49:27 +0100
|
||||
|
||||
rpi-imager (1.7.3) unstable; urgency=medium
|
||||
|
||||
* Linux: use GnuTLS instead of OpenSSL for computing SHA256
|
||||
|
|
|
@ -12,8 +12,8 @@ OPTION (ENABLE_TELEMETRY "Enable sending telemetry" ON)
|
|||
project(rpi-imager LANGUAGES CXX C)
|
||||
set(IMAGER_VERSION_MAJOR 1)
|
||||
set(IMAGER_VERSION_MINOR 7)
|
||||
set(IMAGER_VERSION_STR "${IMAGER_VERSION_MAJOR}.${IMAGER_VERSION_MINOR}.3")
|
||||
set(IMAGER_VERSION_CSV "${IMAGER_VERSION_MAJOR},${IMAGER_VERSION_MINOR},3,0")
|
||||
set(IMAGER_VERSION_STR "${IMAGER_VERSION_MAJOR}.${IMAGER_VERSION_MINOR}.4")
|
||||
set(IMAGER_VERSION_CSV "${IMAGER_VERSION_MAJOR},${IMAGER_VERSION_MINOR},4,0")
|
||||
add_definitions(-DIMAGER_VERSION_STR="${IMAGER_VERSION_STR}")
|
||||
add_definitions(-DIMAGER_VERSION_CSV=${IMAGER_VERSION_CSV})
|
||||
|
||||
|
|
|
@ -483,7 +483,7 @@ bool DeviceWrapperFatPartition::dirNameExists(const QByteArray dirname)
|
|||
while (readDir(&entry))
|
||||
{
|
||||
if (!(entry.DIR_Attr & ATTR_LONG_NAME)
|
||||
&& dirname == QByteArray(entry.DIR_Name, sizeof(entry.DIR_Name)))
|
||||
&& dirname == QByteArray((char *) entry.DIR_Name, sizeof(entry.DIR_Name)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
* Copyright (C) 2022 Raspberry Pi Ltd
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#pragma pack(push, 1)
|
||||
|
||||
/* MBR on-disk structures */
|
||||
|
||||
struct mbr_partition_entry {
|
||||
|
@ -15,7 +19,7 @@ struct mbr_partition_entry {
|
|||
char end_hsc[3];
|
||||
unsigned int starting_sector;
|
||||
unsigned int nr_of_sectors;
|
||||
} __attribute__ ((packed));
|
||||
};
|
||||
|
||||
struct mbr_table {
|
||||
char bootcode[440];
|
||||
|
@ -23,7 +27,7 @@ struct mbr_table {
|
|||
unsigned char flags[2];
|
||||
mbr_partition_entry part[4];
|
||||
unsigned char signature[2];
|
||||
} __attribute__ ((packed));
|
||||
};
|
||||
|
||||
|
||||
/* File Allocation Table
|
||||
|
@ -55,7 +59,7 @@ struct fat16_bpb {
|
|||
|
||||
uint8_t Zeroes[448];
|
||||
uint8_t Signature[2]; /* 0x55aa */
|
||||
} __attribute__ ((packed));
|
||||
};
|
||||
|
||||
struct fat32_bpb {
|
||||
uint8_t BS_jmpBoot[3];
|
||||
|
@ -89,7 +93,7 @@ struct fat32_bpb {
|
|||
|
||||
uint8_t Zeroes[420];
|
||||
uint8_t Signature[2]; /* 0x55aa */
|
||||
} __attribute__ ((packed));
|
||||
};
|
||||
|
||||
union fat_bpb {
|
||||
struct fat16_bpb fat16;
|
||||
|
@ -97,7 +101,7 @@ union fat_bpb {
|
|||
};
|
||||
|
||||
struct dir_entry {
|
||||
char DIR_Name[11];
|
||||
unsigned char DIR_Name[11];
|
||||
uint8_t DIR_Attr;
|
||||
uint8_t DIR_NTRes;
|
||||
uint8_t DIR_CrtTimeTenth;
|
||||
|
@ -109,7 +113,7 @@ struct dir_entry {
|
|||
uint16_t DIR_WrtDate;
|
||||
uint16_t DIR_FstClusLO;
|
||||
uint32_t DIR_FileSize;
|
||||
} __attribute__ ((packed));
|
||||
};
|
||||
|
||||
struct longfn_entry {
|
||||
uint8_t LDIR_Ord;
|
||||
|
@ -120,7 +124,7 @@ struct longfn_entry {
|
|||
char LDIR_Name2[12];
|
||||
uint16_t LDIR_FstClusLO;
|
||||
char LDIR_Name3[4];
|
||||
} __attribute__ ((packed));
|
||||
};
|
||||
|
||||
#define LAST_LONG_ENTRY 0x40
|
||||
|
||||
|
@ -140,6 +144,8 @@ struct FSInfo {
|
|||
uint32_t FSI_Nxt_Free;
|
||||
uint8_t FSI_Reserved2[12];
|
||||
uint8_t FSI_TrailSig[4]; /* 0x00 0x00 0x55 0xAA */
|
||||
} __attribute__ ((packed));
|
||||
};
|
||||
|
||||
#pragma pack(pop)
|
||||
|
||||
#endif // DEVICEWRAPPERSTRUCTS_H
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
#include "downloadthread.h"
|
||||
#include "config.h"
|
||||
#include "devicewrapper.h"
|
||||
#include "devicewrapperfatpartition.h"
|
||||
#include "dependencies/mountutils/src/mountutils.hpp"
|
||||
#include "dependencies/drivelist/src/drivelist.hpp"
|
||||
#include <fstream>
|
||||
|
@ -724,6 +726,15 @@ void DownloadThread::_writeComplete()
|
|||
|
||||
emit finalizing();
|
||||
|
||||
if (!_config.isEmpty() || !_cmdline.isEmpty() || !_firstrun.isEmpty())
|
||||
{
|
||||
if (!_customizeImage())
|
||||
{
|
||||
_closeFiles();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_firstBlock)
|
||||
{
|
||||
qDebug() << "Writing first block (which we skipped at first)";
|
||||
|
@ -741,6 +752,21 @@ void DownloadThread::_writeComplete()
|
|||
_firstBlock = nullptr;
|
||||
}
|
||||
|
||||
if (!_file.flush())
|
||||
{
|
||||
DownloadThread::_onDownloadError(tr("Error writing to storage (while flushing)"));
|
||||
_closeFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
#ifndef Q_OS_WIN
|
||||
if (::fsync(_file.handle()) != 0) {
|
||||
DownloadThread::_onDownloadError(tr("Error writing to storage (while fsync)"));
|
||||
_closeFiles();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
_closeFiles();
|
||||
|
||||
#ifdef Q_OS_DARWIN
|
||||
|
@ -748,18 +774,9 @@ void DownloadThread::_writeComplete()
|
|||
_filename.replace("/dev/rdisk", "/dev/disk");
|
||||
#endif
|
||||
|
||||
if (_ejectEnabled && _config.isEmpty() && _cmdline.isEmpty() && _firstrun.isEmpty())
|
||||
if (_ejectEnabled)
|
||||
eject_disk(_filename.constData());
|
||||
|
||||
if (!_config.isEmpty() || !_cmdline.isEmpty() || !_firstrun.isEmpty())
|
||||
{
|
||||
if (!_customizeImage())
|
||||
return;
|
||||
|
||||
if (_ejectEnabled)
|
||||
eject_disk(_filename.constData());
|
||||
}
|
||||
|
||||
emit success();
|
||||
}
|
||||
|
||||
|
@ -865,325 +882,111 @@ void DownloadThread::setImageCustomization(const QByteArray &config, const QByte
|
|||
|
||||
bool DownloadThread::_customizeImage()
|
||||
{
|
||||
QString folder;
|
||||
std::vector<std::string> mountpoints;
|
||||
QByteArray devlower = _filename.toLower();
|
||||
|
||||
emit preparationStatusUpdate(tr("Waiting for FAT partition to be mounted"));
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
qDebug() << "Running diskpart rescan";
|
||||
QProcess proc;
|
||||
proc.setProcessChannelMode(proc.MergedChannels);
|
||||
proc.start("diskpart");
|
||||
proc.waitForStarted();
|
||||
proc.write("rescan\r\n");
|
||||
proc.closeWriteChannel();
|
||||
proc.waitForFinished();
|
||||
qDebug() << proc.readAll();
|
||||
#endif
|
||||
|
||||
/* See if OS auto-mounted the device */
|
||||
for (int tries = 0; tries < 3; tries++)
|
||||
{
|
||||
QThread::sleep(1);
|
||||
auto l = Drivelist::ListStorageDevices();
|
||||
for (const auto& i : l)
|
||||
{
|
||||
if (QByteArray::fromStdString(i.device).toLower() == devlower && i.mountpoints.size())
|
||||
{
|
||||
mountpoints = i.mountpoints;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
if (mountpoints.empty() && !_nr.isEmpty()) {
|
||||
qDebug() << "Windows did not assign drive letter automatically. Ask diskpart to do so manually.";
|
||||
proc.start("diskpart");
|
||||
proc.waitForStarted();
|
||||
proc.write("select disk "+_nr+"\r\n"
|
||||
"select partition 1\r\n"
|
||||
"assign\r\n");
|
||||
proc.closeWriteChannel();
|
||||
proc.waitForFinished();
|
||||
qDebug() << proc.readAll();
|
||||
|
||||
auto l = Drivelist::ListStorageDevices();
|
||||
for (auto i : l)
|
||||
{
|
||||
if (QByteArray::fromStdString(i.device).toLower() == devlower && i.mountpoints.size())
|
||||
{
|
||||
mountpoints = i.mountpoints;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef Q_OS_LINUX
|
||||
bool manualmount = false;
|
||||
|
||||
if (mountpoints.empty())
|
||||
{
|
||||
/* Manually mount folder */
|
||||
manualmount = true;
|
||||
QByteArray fatpartition = _filename;
|
||||
if (isdigit(fatpartition.at(fatpartition.length()-1)))
|
||||
fatpartition += "p1";
|
||||
else
|
||||
fatpartition += "1";
|
||||
|
||||
if (::access(devlower.constData(), W_OK) != 0)
|
||||
{
|
||||
/* Not running as root, try to outsource mounting to udisks2 */
|
||||
#ifndef QT_NO_DBUS
|
||||
UDisks2Api udisks2;
|
||||
QString mp = udisks2.mountDevice(fatpartition);
|
||||
if (!mp.isEmpty())
|
||||
mountpoints.push_back(mp.toStdString());
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
/* Running as root, attempt running mount directly */
|
||||
QTemporaryDir td;
|
||||
QStringList args;
|
||||
mountpoints.push_back(td.path().toStdString());
|
||||
args << "-t" << "vfat" << fatpartition << td.path();
|
||||
|
||||
if (QProcess::execute("mount", args) != 0)
|
||||
{
|
||||
emit error(tr("Error mounting FAT32 partition"));
|
||||
return false;
|
||||
}
|
||||
td.setAutoRemove(false);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (mountpoints.empty())
|
||||
{
|
||||
//
|
||||
qDebug() << "drive info. searching for:" << devlower;
|
||||
auto l = Drivelist::ListStorageDevices();
|
||||
for (const auto& i : l)
|
||||
{
|
||||
qDebug() << "drive" << QByteArray::fromStdString(i.device).toLower();
|
||||
for (const auto& mp : i.mountpoints) {
|
||||
qDebug() << "mountpoint:" << QByteArray::fromStdString(mp);
|
||||
}
|
||||
}
|
||||
//
|
||||
|
||||
emit error(tr("Operating system did not mount FAT32 partition"));
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Some operating system take longer to complete mounting FAT32
|
||||
wait up to 3 seconds for config.txt file to appear */
|
||||
QString configFilename;
|
||||
bool foundFile = false;
|
||||
|
||||
for (int tries = 0; tries < 3; tries++)
|
||||
{
|
||||
/* Search all mountpoints, as on some systems FAT partition
|
||||
may not be first volume */
|
||||
for (const auto& mp : mountpoints)
|
||||
{
|
||||
folder = QString::fromStdString(mp);
|
||||
if (folder.right(1) == '\\')
|
||||
folder.chop(1);
|
||||
configFilename = folder+"/config.txt";
|
||||
|
||||
if (QFile::exists(configFilename))
|
||||
{
|
||||
foundFile = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundFile)
|
||||
break;
|
||||
QThread::sleep(1);
|
||||
}
|
||||
|
||||
if (!foundFile)
|
||||
{
|
||||
emit error(tr("Unable to customize. File '%1' does not exist.").arg(configFilename));
|
||||
return false;
|
||||
}
|
||||
|
||||
emit preparationStatusUpdate(tr("Customizing image"));
|
||||
|
||||
if (!_config.isEmpty())
|
||||
try
|
||||
{
|
||||
auto configItems = _config.split('\n');
|
||||
configItems.removeAll("");
|
||||
QByteArray config;
|
||||
|
||||
QFile f(configFilename);
|
||||
if (f.open(f.ReadOnly))
|
||||
DeviceWrapper dw(&_file);
|
||||
if (_firstBlock)
|
||||
{
|
||||
config = f.readAll();
|
||||
f.close();
|
||||
/* Outsource first block handling to DeviceWrapper.
|
||||
It will still not actually be written out yet,
|
||||
until we call sync(), and then it will
|
||||
save the first 4k sector with MBR for last */
|
||||
dw.pwrite(_firstBlock, _firstBlockSize, 0);
|
||||
_bytesWritten += _firstBlockSize;
|
||||
qFreeAligned(_firstBlock);
|
||||
_firstBlock = nullptr;
|
||||
}
|
||||
DeviceWrapperFatPartition *fat = dw.fatPartition(1);
|
||||
|
||||
if (!_config.isEmpty())
|
||||
{
|
||||
auto configItems = _config.split('\n');
|
||||
configItems.removeAll("");
|
||||
QByteArray config = fat->readFile("config.txt");
|
||||
|
||||
for (const QByteArray& item : qAsConst(configItems))
|
||||
{
|
||||
if (config.contains("#"+item)) {
|
||||
/* Uncomment existing line */
|
||||
config.replace("#"+item, item);
|
||||
} else if (config.contains("\n"+item)) {
|
||||
/* config.txt already contains the line */
|
||||
} else {
|
||||
/* Append new line to config.txt */
|
||||
if (config.right(1) != "\n")
|
||||
config += "\n"+item+"\n";
|
||||
else
|
||||
config += item+"\n";
|
||||
}
|
||||
}
|
||||
|
||||
fat->writeFile("config.txt", config);
|
||||
}
|
||||
|
||||
for (const QByteArray& item : qAsConst(configItems))
|
||||
if (_initFormat == "auto")
|
||||
{
|
||||
if (config.contains("#"+item)) {
|
||||
/* Uncomment existing line */
|
||||
config.replace("#"+item, item);
|
||||
} else if (config.contains("\n"+item)) {
|
||||
/* config.txt already contains the line */
|
||||
} else {
|
||||
/* Append new line to config.txt */
|
||||
if (config.right(1) != "\n")
|
||||
config += "\n"+item+"\n";
|
||||
else
|
||||
config += item+"\n";
|
||||
/* Do an attempt at auto-detecting what customization format a custom
|
||||
image provided by the user supports */
|
||||
QByteArray issue = fat->readFile("issue.txt");
|
||||
|
||||
if (fat->fileExists("user-data"))
|
||||
{
|
||||
/* If we have user-data file on FAT partition, then it must be cloudinit */
|
||||
_initFormat = "cloudinit";
|
||||
qDebug() << "user-data found on FAT partition. Assuming cloudinit support";
|
||||
}
|
||||
else if (issue.contains("pi-gen"))
|
||||
{
|
||||
/* If issue.txt mentions pi-gen, and there is no user-data file assume
|
||||
* it is a RPI OS flavor, and use the old systemd unit firstrun script stuff */
|
||||
_initFormat = "systemd";
|
||||
qDebug() << "using firstrun script invoked by systemd customization method";
|
||||
}
|
||||
else
|
||||
{
|
||||
/* Fallback to writing cloudinit file, as it does not hurt having one
|
||||
* Will just have no customization if OS does not support it */
|
||||
_initFormat = "cloudinit";
|
||||
qDebug() << "Unknown what customization method image supports. Falling back to cloudinit";
|
||||
}
|
||||
}
|
||||
|
||||
if (f.open(f.WriteOnly) && f.write(config) == config.length())
|
||||
if (!_firstrun.isEmpty() && _initFormat == "systemd")
|
||||
{
|
||||
f.close();
|
||||
fat->writeFile("firstrun.sh", _firstrun);
|
||||
_cmdline += " systemd.run=/boot/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target";
|
||||
}
|
||||
else
|
||||
|
||||
if (!_cloudinit.isEmpty() && _initFormat == "cloudinit")
|
||||
{
|
||||
emit error(tr("Error writing to config.txt on FAT partition"));
|
||||
return false;
|
||||
_cloudinit = "#cloud-config\n"+_cloudinit;
|
||||
fat->writeFile("user-data", _cloudinit);
|
||||
}
|
||||
|
||||
if (!_cloudinitNetwork.isEmpty() && _initFormat == "cloudinit")
|
||||
{
|
||||
fat->writeFile("network-config", _cloudinitNetwork);
|
||||
}
|
||||
|
||||
if (!_cmdline.isEmpty())
|
||||
{
|
||||
QByteArray cmdline = fat->readFile("cmdline.txt").trimmed();
|
||||
|
||||
cmdline += _cmdline;
|
||||
|
||||
fat->writeFile("cmdline.txt", cmdline);
|
||||
}
|
||||
dw.sync();
|
||||
}
|
||||
|
||||
if (_initFormat == "auto")
|
||||
catch (std::runtime_error &err)
|
||||
{
|
||||
/* Do an attempt at auto-detecting what customization format a custom
|
||||
image provided by the user supports */
|
||||
QByteArray issue;
|
||||
QFile fi(folder+"/issue.txt");
|
||||
if (fi.exists() && fi.open(fi.ReadOnly))
|
||||
{
|
||||
issue = fi.readAll();
|
||||
fi.close();
|
||||
}
|
||||
|
||||
if (QFile::exists(folder+"/user-data"))
|
||||
{
|
||||
/* If we have user-data file on FAT partition, then it must be cloudinit */
|
||||
_initFormat = "cloudinit";
|
||||
qDebug() << "user-data found on FAT partition. Assuming cloudinit support";
|
||||
}
|
||||
else if (issue.contains("pi-gen"))
|
||||
{
|
||||
/* If issue.txt mentions pi-gen, and there is no user-data file assume
|
||||
* it is a RPI OS flavor, and use the old systemd unit firstrun script stuff */
|
||||
_initFormat = "systemd";
|
||||
qDebug() << "using firstrun script invoked by systemd customization method";
|
||||
}
|
||||
else
|
||||
{
|
||||
/* Fallback to writing cloudinit file, as it does not hurt having one
|
||||
* Will just have no customization if OS does not support it */
|
||||
_initFormat = "cloudinit";
|
||||
qDebug() << "Unknown what customization method image supports. Falling back to cloudinit";
|
||||
}
|
||||
}
|
||||
|
||||
if (!_firstrun.isEmpty() && _initFormat == "systemd")
|
||||
{
|
||||
QFile f(folder+"/firstrun.sh");
|
||||
if (f.open(f.WriteOnly) && f.write(_firstrun) == _firstrun.length())
|
||||
{
|
||||
f.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
emit error(tr("Error creating firstrun.sh on FAT partition"));
|
||||
return false;
|
||||
}
|
||||
|
||||
_cmdline += " systemd.run=/boot/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target";
|
||||
}
|
||||
|
||||
if (!_cloudinit.isEmpty() && _initFormat == "cloudinit")
|
||||
{
|
||||
_cloudinit = "#cloud-config\n"+_cloudinit;
|
||||
QFile f(folder+"/user-data");
|
||||
if (f.open(f.WriteOnly) && f.write(_cloudinit) == _cloudinit.length())
|
||||
{
|
||||
f.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
emit error(tr("Error creating user-data cloudinit file on FAT partition"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_cloudinitNetwork.isEmpty() && _initFormat == "cloudinit")
|
||||
{
|
||||
QFile f(folder+"/network-config");
|
||||
if (f.open(f.WriteOnly) && f.write(_cloudinitNetwork) == _cloudinitNetwork.length())
|
||||
{
|
||||
f.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
emit error(tr("Error creating network-config cloudinit file on FAT partition"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_cmdline.isEmpty())
|
||||
{
|
||||
QByteArray cmdline;
|
||||
|
||||
QFile f(folder+"/cmdline.txt");
|
||||
if (f.exists() && f.open(f.ReadOnly))
|
||||
{
|
||||
cmdline = f.readAll().trimmed();
|
||||
f.close();
|
||||
}
|
||||
|
||||
cmdline += _cmdline;
|
||||
if (f.open(f.WriteOnly) && f.write(cmdline) == cmdline.length())
|
||||
{
|
||||
f.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
emit error(tr("Error writing to cmdline.txt on FAT partition"));
|
||||
return false;
|
||||
}
|
||||
emit error(err.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
emit finalizing();
|
||||
|
||||
#ifdef Q_OS_LINUX
|
||||
if (manualmount)
|
||||
{
|
||||
if (::access(devlower.constData(), W_OK) != 0)
|
||||
{
|
||||
#ifndef QT_NO_DBUS
|
||||
UDisks2Api udisks2;
|
||||
udisks2.unmountDrive(devlower);
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
QStringList args;
|
||||
args << folder;
|
||||
QProcess::execute("umount", args);
|
||||
QDir d;
|
||||
d.rmdir(folder);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifndef Q_OS_WIN
|
||||
::sync();
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue