Migrated away from .idt tables in favor of a single .sql file that updates the MSI post-creation.

Added support for installing a `preload.json` file by setting the MSI option `INSTALL_PRELOAD=true` and then placing a `preload.json` file in the same folder as where the MSI is located.
This commit is contained in:
Kenneth Skovhede
2026-05-29 14:22:26 +02:00
parent 5fdcc644d7
commit 1f7b30a673
8 changed files with 183 additions and 29 deletions
+13 -6
View File
@@ -560,19 +560,26 @@ public static partial class Command
await ProcessHelper.Execute(wixArgs, workingDirectory: buildRoot);
// Apply any installer data tables present
// On Windows, the wxs files include the tables, but on Linux/MacOS, we need to apply them separately
// because wixl does not support the tables directly
// Apply post-wixl MSI table customizations on Linux/macOS. On Windows the
// real WiX toolchain handles all of these natively via WXS elements
if (!OperatingSystem.IsWindows())
{
if (string.IsNullOrWhiteSpace(rtcfg.Configuration.Commands.MsiBuild))
{
Console.WriteLine("WARNING: msiBuild not found, skipping IDT file injection.");
Console.WriteLine("WARNING: msiBuild not found, skipping SQL file injection.");
}
else
{
foreach (var idtFile in Directory.GetFiles(resourcesSubDir, "*.idt"))
await ProcessHelper.Execute([rtcfg.Configuration.Commands.MsiBuild, msiFile, "-i", idtFile]);
foreach (var sqlFile in Directory.GetFiles(resourcesSubDir, "*.sql"))
{
var queries = (await File.ReadAllLinesAsync(sqlFile))
.Select(line => line.Trim())
.Where(line => line.Length > 0 && !line.StartsWith("--"))
.ToArray();
foreach (var query in queries)
await ProcessHelper.Execute([rtcfg.Configuration.Commands.MsiBuild, msiFile, "-q", query]);
}
}
}
@@ -35,6 +35,12 @@
<Property Id="FORSERVICE" Value="false" Secure="yes" />
<Property Id="NOSCRIPT" Value="false" Secure="yes" />
<!-- Opt-in switch for installing a preload.json -->
<Property Id="INSTALL_PRELOAD" Secure="yes" />
<!-- Property from SetPreloadSource pointing to [SourceDir] -->
<Property Id="PRELOAD_SOURCE" Secure="yes" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramMenuFolder" Name="Programs"/>
<Directory Id="DesktopFolder" Name="Desktop"/>
@@ -87,6 +93,9 @@
<!-- Stop and start service must be deferred actions between InstallInitialize and InstallFinalize -->
<Custom Action="StopService" After="InstallInitialize">SERVICE_WAS_STOPPED=1 AND NOT UPGRADINGPRODUCTCODE</Custom>
<Custom Action="StartService" After="InstallFiles">SERVICE_WAS_STOPPED=1 AND NOT REMOVE~="ALL" AND NOT UPGRADINGPRODUCTCODE</Custom>
<!-- Resolves [SourceDir] into PRELOAD_SOURCE -->
<Custom Action="SetPreloadSource" After="CostFinalize">INSTALL_PRELOAD="true" AND NOT Installed</Custom>
</InstallExecuteSequence>
<Property Id="PREVIOUSVERSIONSINSTALLED" Secure="yes" />
@@ -119,5 +128,12 @@
JScriptCall="CustomAction"
Execute="deferred"
Return="check" />
<!-- Resolves [SourceDir] into the PRELOAD_SOURCE property -->
<CustomAction Id="SetPreloadSource"
Property="PRELOAD_SOURCE"
Value="[SourceDir]"
Execute="immediate"
Return="check" />
</Product>
</Wix>
@@ -0,0 +1,60 @@
-- Post-wixl MSI table customizations for the Agent installer.
--
-- The file is processed by Command.CreatePackage.cs after wixl produces the
-- MSI: each non-empty, non-comment line is run as a single `msibuild -q`
-- query. Statements are executed in order; lines beginning with `--` and
-- blank lines are ignored.
--
-- Why this exists: wixl 0.106 does not understand WiX elements such as
-- <util:PermissionEx> or <CopyFile> (on a Component) - it silently drops
-- them or fails to build. On Windows the real WiX toolchain emits the
-- corresponding tables natively from the WXS sources; on Linux/macOS we
-- have to author the rows directly. Statements that touch tables wixl
-- already emits (RemoveFile, InstallExecuteSequence) must use INSERT only,
-- never CREATE, because `msibuild -q "CREATE TABLE foo"` against an
-- existing table fails.
-- ---------------------------------------------------------------------------
-- MsiLockPermissionsEx: ACLs for the install dir. Replaces the WiX
-- <util:PermissionEx> element on SecureInstallDirComp that wixl cannot parse.
-- ---------------------------------------------------------------------------
CREATE TABLE `MsiLockPermissionsEx` (`MsiLockPermissionsEx` CHAR(72) NOT NULL, `LockObject` CHAR(72) NOT NULL, `Table` CHAR(32) NOT NULL, `SDDLText` CHAR(255) NOT NULL, `Condition` CHAR(255) PRIMARY KEY `MsiLockPermissionsEx`, `LockObject`, `Table`)
INSERT INTO `MsiLockPermissionsEx` (`MsiLockPermissionsEx`, `LockObject`, `Table`, `SDDLText`) VALUES ('SecureFolderACLs', 'INSTALLLOCATION', 'CreateFolder', 'D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRFX;;;BU)')
-- ---------------------------------------------------------------------------
-- MoveFile: copies preload.json from the directory containing the MSI
-- (resolved into PRELOAD_SOURCE by SetPreloadSource) into INSTALLLOCATION
-- when the user passes INSTALL_PRELOAD=true on the msiexec command line.
-- The MoveFile row is keyed to the always-installed Duplicati.Agent.exe
-- component so it ships with the rest of the binaries and follows their
-- install/uninstall lifecycle. Options=0 means copy (not move). Replaces
-- the WiX <CopyFile> element that wixl cannot parse on a Component without
-- a <File> child.
--
-- The standard MoveFiles action that processes this table is scheduled
-- further below in this file.
-- ---------------------------------------------------------------------------
CREATE TABLE `MoveFile` (`FileKey` CHAR(72) NOT NULL, `Component_` CHAR(72) NOT NULL, `SourceName` CHAR(255) LOCALIZABLE, `DestName` CHAR(255) LOCALIZABLE, `SourceFolder` CHAR(72), `DestFolder` CHAR(72) NOT NULL, `Options` SHORT NOT NULL PRIMARY KEY `FileKey`)
INSERT INTO `MoveFile` (`FileKey`, `Component_`, `SourceName`, `DestName`, `SourceFolder`, `DestFolder`, `Options`) VALUES ('CopyPreloadJson', 'Duplicati.Agent.exe', 'preload.json', 'preload.json', 'PRELOAD_SOURCE', 'INSTALLLOCATION', 0)
-- ---------------------------------------------------------------------------
-- RemoveFile: removes preload.json from INSTALLLOCATION on uninstall
-- (InstallMode=2 = msidbRemoveFileInstallModeOnRemove). The RemoveFile
-- table is already created by wixl so we only INSERT here.
-- ---------------------------------------------------------------------------
INSERT INTO `RemoveFile` (`FileKey`, `Component_`, `FileName`, `DirProperty`, `InstallMode`) VALUES ('RemovePreloadJson', 'Duplicati.Agent.exe', 'preload.json', 'INSTALLLOCATION', 2)
-- ---------------------------------------------------------------------------
-- InstallExecuteSequence: schedule the standard ResolveSource and MoveFiles
-- actions so the MoveFile row above is actually processed. wixl does not
-- auto-insert these standard actions because their respective tables are
-- empty at wixl build time. Both are gated by INSTALL_PRELOAD="true" so they
-- only fire when the user explicitly opts in.
-- ResolveSource @ 850 - populates [SourceDir] before SetPreloadSource
-- consumes it (SetPreloadSource itself is declared in the WXS at ~1001
-- via <Custom Action="SetPreloadSource" After="CostFinalize">).
-- MoveFiles @ 3700 - canonical position between RemoveFiles (3500)
-- and InstallFiles (4000).
-- ---------------------------------------------------------------------------
INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES ('ResolveSource', 'INSTALL_PRELOAD="true" AND NOT Installed', 850)
INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES ('MoveFiles', 'INSTALL_PRELOAD="true"', 3700)
@@ -1,4 +0,0 @@
MsiLockPermissionsEx LockObject Table SDDLText Condition
s72 s72 s32 s255 S255
MsiLockPermissionsEx MsiLockPermissionsEx LockObject Table
SecureFolderACLs INSTALLLOCATION CreateFolder D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRFX;;;BU)
@@ -54,6 +54,12 @@
elevated execution sequence. -->
<Property Id="INSTALL_TLS_CERTS" Secure="yes" />
<!-- Opt-in switch for installing a preload.json -->
<Property Id="INSTALL_PRELOAD" Secure="yes" />
<!-- Property from SetPreloadSource pointing to [SourceDir] -->
<Property Id="PRELOAD_SOURCE" Secure="yes" />
<!-- Set to "true" by InstallOptionsDlg when the user opts into installing
Duplicati as a Windows Service. Currently used only as a UI hint;
the actual server-side behaviour is driven by the action state of
@@ -90,7 +96,7 @@
<Directory Id="INSTALLLOCATION" Name="Duplicati 2" >
<Component Id="SecureInstallDirComp" Guid="7C52A563-7182-411E-8B57-79F881775E9B">
<CreateFolder>
<!-- WiX supports permssions, wixl uses a .idt table -->
<!-- WiX supports permssions, wixl uses sql -->
<?if $(var.BuildEnv) = "Windows" ?>
<util:PermissionEx Sddl="D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRFX;;;BU)" xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"/>
<?endif?>
@@ -105,8 +111,9 @@
Two registry keys are managed here:
* Service - holds the Installed=1 keypath value AND
the runtime-generated InitPassword value.
The key's ACL is hardened by
MsiLockPermissionsEx.idt.
The key's ACL is hardened by the
MsiLockPermissionsEx row in
InstallTables.sql.
* ServiceState - holds the BootstrapApplied sentinel that
the service writes once it has applied
@@ -126,8 +133,8 @@
first start. -->
<RegistryKey Root="HKLM" Key="$(var.SoftwareKey)\DuplicatiTeam\Duplicati\Service">
<!-- Id is fixed (not auto-generated) so the
MsiLockPermissionsEx.idt row that hardens this key's ACL
can reference a stable row primary key. -->
MsiLockPermissionsEx row in InstallTables.sql that hardens
this key's ACL can reference a stable row primary key. -->
<RegistryValue Id="ServiceInstalledMarker" Name="Installed" Value="1" Type="string" KeyPath="yes" />
<RegistryValue Id="ServiceInitPasswordValue" Name="InitPassword" Value="[DUPLICATI_SERVICE_PASSWORD]" Type="string" />
</RegistryKey>
@@ -141,7 +148,8 @@
<RegistryKey Root="HKLM" Key="$(var.SoftwareKey)\DuplicatiTeam\Duplicati\ServiceState">
<RegistryValue Id="ServiceStateInstalledMarker" Name="Installed" Value="1" Type="string" />
<!-- Make sure we remove the BootstrapApplied value if it exists,
which is handled by RemoveRegistry.idt on non-Windows.
which is handled by the RemoveRegistry row in
InstallTables.sql on non-Windows.
The removal is required to ensure we do not pick up a stale value -->
<?if $(var.BuildEnv) = "Windows" ?>
<RemoveRegistryValue Id="RemoveStaleBootstrapApplied" Name="BootstrapApplied" Action="removeOnInstall" />
@@ -238,6 +246,9 @@
<Custom Action="SetNoHostedServerArg" Before="CreateShortcuts">&amp;DuplicatiServiceFeature=3</Custom>
<Custom Action="ClearNoHostedServerArg" Before="CreateShortcuts">NOT (&amp;DuplicatiServiceFeature=3)</Custom>
<!-- Resolves [SourceDir] into PRELOAD_SOURCE -->
<Custom Action="SetPreloadSource" After="CostFinalize">INSTALL_PRELOAD="true" AND NOT Installed</Custom>
<!--
Service install pipeline (only when the service feature is freshly
installed, gated by the same condition):
@@ -251,9 +262,9 @@
the InitPassword value into the protected Service registry key
owned by DuplicatiServiceMarkerComponent, substituting in the
current value of DUPLICATI_SERVICE_PASSWORD. The key's ACL is
hardened by a row in the hand-authored MsiLockPermissionsEx.idt
table (injected by msibuild after wixl runs) so non-admin users
cannot read the password value.
hardened by a row in the hand-authored MsiLockPermissionsEx
table from InstallTables.sql (injected by msibuild after wixl
runs) so non-admin users cannot read the password value.
3. InstallDuplicatiService (sequenced AFTER WriteRegistryValues so
the password is already in the registry before SCM starts the
service) registers the Duplicati Windows Service. The service
@@ -364,6 +375,13 @@
Execute="immediate"
Return="check" />
<!-- Resolves [SourceDir] into the PRELOAD_SOURCE property -->
<CustomAction Id="SetPreloadSource"
Property="PRELOAD_SOURCE"
Value="[SourceDir]"
Execute="immediate"
Return="check" />
<!-- Service install/uninstall custom actions.
The installer invokes Duplicati.WindowsService.exe via a JScript
helper (SilentRun.js) so the console window stays hidden.
@@ -0,0 +1,67 @@
-- Post-wixl MSI table customizations for the TrayIcon installer.
--
-- The file is processed by Command.CreatePackage.cs after wixl produces the
-- MSI: each non-empty, non-comment line is run as a single `msibuild -q`
-- query. Statements are executed in order; lines beginning with `--` and
-- blank lines are ignored.
--
-- This enables certain customizations that wixl cannot handle natively.
-- ---------------------------------------------------------------------------
-- MsiLockPermissionsEx: ACLs for the install dir and the protected
-- Service / ServiceState registry keys created by
-- DuplicatiServiceMarkerComponent. Replaces the WiX <util:PermissionEx>
-- elements that wixl cannot parse.
-- ---------------------------------------------------------------------------
CREATE TABLE `MsiLockPermissionsEx` (`MsiLockPermissionsEx` CHAR(72) NOT NULL, `LockObject` CHAR(72) NOT NULL, `Table` CHAR(32) NOT NULL, `SDDLText` CHAR(255) NOT NULL, `Condition` CHAR(255) PRIMARY KEY `MsiLockPermissionsEx`, `LockObject`, `Table`)
INSERT INTO `MsiLockPermissionsEx` (`MsiLockPermissionsEx`, `LockObject`, `Table`, `SDDLText`) VALUES ('SecureFolderACLs', 'INSTALLLOCATION', 'CreateFolder', 'D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRFX;;;BU)')
INSERT INTO `MsiLockPermissionsEx` (`MsiLockPermissionsEx`, `LockObject`, `Table`, `SDDLText`) VALUES ('SecureServiceRegKey', 'ServiceInstalledMarker', 'Registry', 'D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)')
INSERT INTO `MsiLockPermissionsEx` (`MsiLockPermissionsEx`, `LockObject`, `Table`, `SDDLText`) VALUES ('OpenServiceStateRegKey', 'ServiceStateInstalledMarker', 'Registry', 'D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)(A;OICI;KR;;;AU)(A;OICI;KR;;;BU)')
-- ---------------------------------------------------------------------------
-- RemoveRegistry: deletes the BootstrapApplied sentinel value on install so
-- a stale value from a previous install does not get re-picked-up by the
-- service. Replaces the WiX <RemoveRegistryValue Action="removeOnInstall"/>
-- inside DuplicatiServiceMarkerComponent (wixl does not emit this).
-- ---------------------------------------------------------------------------
CREATE TABLE `RemoveRegistry` (`RemoveRegistry` CHAR(72) NOT NULL, `Root` SHORT NOT NULL, `Key` CHAR(255) NOT NULL LOCALIZABLE, `Name` CHAR(255) LOCALIZABLE, `Component_` CHAR(72) NOT NULL PRIMARY KEY `RemoveRegistry`)
INSERT INTO `RemoveRegistry` (`RemoveRegistry`, `Root`, `Key`, `Name`, `Component_`) VALUES ('RemoveStaleBootstrapApplied', 2, 'SOFTWARE\DuplicatiTeam\Duplicati\ServiceState', 'BootstrapApplied', 'DuplicatiServiceMarkerComponent')
-- ---------------------------------------------------------------------------
-- MoveFile: copies preload.json from the directory containing the MSI
-- (resolved into PRELOAD_SOURCE by SetPreloadSource) into INSTALLLOCATION
-- when the user passes INSTALL_PRELOAD=true on the msiexec command line.
-- The MoveFile row is keyed to the always-installed Duplicati.GUI.TrayIcon.exe
-- component so it ships with the rest of the binaries and follows their
-- install/uninstall lifecycle. Options=0 means copy (not move). Replaces the
-- WiX <CopyFile> element that wixl cannot parse on a Component without a
-- <File> child.
--
-- The standard MoveFiles action that processes this table is scheduled
-- further below in this file.
-- ---------------------------------------------------------------------------
CREATE TABLE `MoveFile` (`FileKey` CHAR(72) NOT NULL, `Component_` CHAR(72) NOT NULL, `SourceName` CHAR(255) LOCALIZABLE, `DestName` CHAR(255) LOCALIZABLE, `SourceFolder` CHAR(72), `DestFolder` CHAR(72) NOT NULL, `Options` SHORT NOT NULL PRIMARY KEY `FileKey`)
INSERT INTO `MoveFile` (`FileKey`, `Component_`, `SourceName`, `DestName`, `SourceFolder`, `DestFolder`, `Options`) VALUES ('CopyPreloadJson', 'Duplicati.GUI.TrayIcon.exe', 'preload.json', 'preload.json', 'PRELOAD_SOURCE', 'INSTALLLOCATION', 0)
-- ---------------------------------------------------------------------------
-- RemoveFile: removes preload.json from INSTALLLOCATION on uninstall
-- (InstallMode=2 = msidbRemoveFileInstallModeOnRemove). The RemoveFile
-- table is already created by wixl so we only INSERT here. Replaces the
-- WiX <RemoveFile> element under the preload component.
-- ---------------------------------------------------------------------------
INSERT INTO `RemoveFile` (`FileKey`, `Component_`, `FileName`, `DirProperty`, `InstallMode`) VALUES ('RemovePreloadJson', 'Duplicati.GUI.TrayIcon.exe', 'preload.json', 'INSTALLLOCATION', 2)
-- ---------------------------------------------------------------------------
-- InstallExecuteSequence: schedule the standard ResolveSource and MoveFiles
-- actions so the MoveFile row above is actually processed. wixl does not
-- auto-insert these standard actions because their respective tables are
-- empty at wixl build time. Both are gated by INSTALL_PRELOAD="true" so they
-- only fire when the user explicitly opts in.
-- ResolveSource @ 850 - populates [SourceDir] before SetPreloadSource
-- consumes it (SetPreloadSource itself is declared in the WXS at ~1001
-- via <Custom Action="SetPreloadSource" After="CostFinalize">).
-- MoveFiles @ 3700 - canonical position between RemoveFiles (3500)
-- and InstallFiles (4000).
-- ---------------------------------------------------------------------------
INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES ('ResolveSource', 'INSTALL_PRELOAD="true" AND NOT Installed', 850)
INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES ('MoveFiles', 'INSTALL_PRELOAD="true"', 3700)
@@ -1,6 +0,0 @@
MsiLockPermissionsEx LockObject Table SDDLText Condition
s72 s72 s32 s255 S255
MsiLockPermissionsEx MsiLockPermissionsEx LockObject Table
SecureFolderACLs INSTALLLOCATION CreateFolder D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRFX;;;BU)
SecureServiceRegKey ServiceInstalledMarker Registry D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)
OpenServiceStateRegKey ServiceStateInstalledMarker Registry D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)(A;OICI;KR;;;AU)(A;OICI;KR;;;BU)
@@ -1,4 +0,0 @@
RemoveRegistry Root Key Name Component_
s72 i2 l255 L255 s72
RemoveRegistry RemoveRegistry
RemoveStaleBootstrapApplied 2 SOFTWARE\DuplicatiTeam\Duplicati\ServiceState BootstrapApplied DuplicatiServiceMarkerComponent