{** * Provides environmental and configuration parameters for the installation. * Includes functionality for querying online API for extra metadata such as * version and location of .msi to be installed. *} unit Keyman.Setup.System.InstallInfo; interface uses System.Classes, System.Generics.Collections, System.SysUtils, PackageInfo, SetupStrings; type EInstallInfo = class(Exception); TMSIInfo = record Version, ProductCode: string; end; TInstallInfoLocationType = (iilLocal, iilOnline); TInstallInfoPackageLanguage = class private FName: string; FBCP47: string; public constructor Create(const ABCP47, AName: string); property BCP47: string read FBCP47; property Name: string read FName; end; TInstallInfoPackageLanguages = class(TObjectlist) end; TInstallInfoFileLocation = class private FLocationType: TInstallInfoLocationType; FUrl: string; FSize: Integer; FVersion: string; FPath: string; FProductCode: string; public constructor Create(ALocationType: TInstallInfoLocationType); virtual; procedure UpgradeToLocalPath(const ARootPath: string); property LocationType: TInstallInfoLocationType read FLocationType; property Size: Integer read FSize write FSize; property Path: string read FPath write FPath; property Url: string read FUrl write FUrl; property ProductCode: string read FProductCode write FProductCode; // used only by msi property Version: string read FVersion write FVersion; end; TInstallInfoFileLocations = class(TObjectList) public function LatestVersion(const default: string = '0'): string; end; TInstallInfoPackageFileLocation = class(TInstallInfoFileLocation) private FName: string; FLanguages: TInstallInfoPackageLanguages; FLocalPackage: TPackage; public constructor Create(ALocationType: TInstallInfoLocationType); override; destructor Destroy; override; function GetNameOrID(const id: string): string; function GetLanguageNameFromBCP47(const bcp47: string): string; property Name: string read FName write FName; property Languages: TInstallInfoPackageLanguages read FLanguages; property LocalPackage: TPackage read FLocalPackage; end; TInstallInfoPackageFileLocations = class(TObjectList) public function LatestVersion(const default: string = '0'): string; end; TInstallInfoPackage = class private FID: string; FBCP47: string; FLocations: TInstallInfoPackageFileLocations; FShouldInstall: Boolean; public constructor Create(const AID: string; const ABCP47: string = ''); destructor Destroy; override; function GetBestLocation: TInstallInfoPackageFileLocation; // TODO: make private and calculate in CheckMsiAndPackageUpgradeScenarios property ID: string read FID; property BCP47: string read FBCP47 write FBCP47; property ShouldInstall: Boolean read FShouldInstall write FShouldInstall; property Locations: TInstallInfoPackageFileLocations read FLocations; end; TInstallInfoPackages = class(TObjectList) function FindById(const id: string; createIfNotFound: Boolean): TInstallInfoPackage; end; TInstallInfo = class private FAppName: WideString; FMSIOptions: string; // I3126 FPackages: TInstallInfoPackages; FStrings: TStrings; FTitleImageFilename: string; FStartDisabled: Boolean; FStartWithConfiguration: Boolean; FMsiLocations: TInstallInfoFileLocations; FBestMsi: TInstallInfoFileLocation; FInstalledVersion: TMSIInfo; FIsInstalled: Boolean; FIsNewerAvailable: Boolean; FTempPath: string; FShouldInstallKeyman: Boolean; FTier: string; function GetBestMsi: TInstallInfoFileLocation; function GetPackageMetadata(const KmpFilename: string; p: TPackage): Boolean; public constructor Create(const ATempPath: string); destructor Destroy; override; procedure LoadSetupInf(const SetupInfPath: string); procedure LocatePackagesAndTierFromFilename(Filename: string); procedure LocatePackagesFromParameter(const Param: string); procedure LocatePackagesInPath(const path: string); function CheckMsiAndPackageUpgradeScenarios: Boolean; function Text(const Name: TInstallInfoText): WideString; overload; function Text(const Name: TInstallInfoText; const Args: array of const): WideString; overload; property TempPath: string read FTempPath; property Strings: TStrings read FStrings; property EditionTitle: WideString read FAppName; property MsiLocations: TInstallInfoFileLocations read FMsiLocations; property BestMsi: TInstallInfoFileLocation read GetBestMsi; // Keyman Installer information property InstalledVersion: TMSIInfo read FInstalledVersion write FInstalledVersion; property IsInstalled: Boolean read FIsInstalled; property IsNewerAvailable: Boolean read FIsNewerAvailable; property MSIOptions: string read FMSIOptions; // I3126 property Packages: TInstallInfoPackages read FPackages; property TitleImageFilename: string read FTitleImageFilename; property StartDisabled: Boolean read FStartDisabled; property StartWithConfiguration: Boolean read FStartWithConfiguration; property Tier: string read FTier write FTier; property ShouldInstallKeyman: Boolean read FShouldInstallKeyman write FShouldInstallKeyman; end; implementation uses System.RegularExpressions, System.StrUtils, System.TypInfo, System.Zip, Winapi.Windows, KeymanVersion, kmpinffile, utilfiletypes, versioninfo; function FileSizeByName(const AFilename: string): Int64; forward; { TInstallInfo } constructor TInstallInfo.Create(const ATempPath: string); begin inherited Create; FTempPath := ATempPath; FAppName := SKeymanDesktopName; FMsiLocations := TInstallInfoFileLocations.Create; FPackages := TInstallInfoPackages.Create; FStrings := TStringList.Create; FTier := KeymanVersion.CKeymanVersionInfo.Tier; FShouldInstallKeyman := True; end; destructor TInstallInfo.Destroy; begin FreeAndNil(FPackages); FreeAndNil(FStrings); FreeAndNil(FMsiLocations); inherited Destroy; end; function TInstallInfo.GetBestMsi: TInstallInfoFileLocation; var location: TInstallInfoFileLocation; begin if FBestMsi <> nil then Exit(FBestMsi); if FMsiLocations.Count = 0 then Exit(nil); Result := FMsiLocations[0]; for location in FMsiLocations do case CompareVersions(Result.Version, location.Version) of 0: if Result.LocationType = iilOnline then Result := location; // prefer local for same version 1: Result := location; end; FBestMsi := Result; end; function TInstallInfo.Text(const Name: TInstallInfoText): WideString; var s: WideString; begin s := GetEnumName(TypeInfo(TInstallInfoText), Ord(Name)); Result := FStrings.Values[s]; if Result = '' then Result := FDefaultStrings[Name]; Result := ReplaceText(Result, '$VERSION', SKeymanVersion); Result := ReplaceText(Result, '$APPNAME', FAppName); end; procedure TInstallInfo.LoadSetupInf(const SetupInfPath: string); var i: Integer; FInSetup: Boolean; FInPackages: Boolean; FInStrings: Boolean; location: TInstallInfoFileLocation; val, nm: WideString; pack: TInstallInfoPackage; packLocation: TInstallInfoPackageFileLocation; res: TMatch; FVersion, FMSIFileName: string; begin FInSetup := False; FInPackages := False; FInStrings := False; with TStringList.Create do try LoadFromFile(SetupInfPath + 'setup.inf'); // We'll just use the preamble for encoding // I3476 for i := 0 to Count - 1 do begin if Trim(Strings[i]) = '' then Continue; if Copy(Strings[i], 1, 1) = '[' then begin FInSetup := WideSameText(Strings[i], '[Setup]'); FInPackages := WideSameText(Strings[i], '[Packages]'); FInStrings := WideSameText(Strings[i], '[Strings]'); end else if FInSetup then begin nm := Names[i]; val := ValueFromIndex[i]; if WideSameText(nm, 'Version') then FVersion := val else if WideSameText(nm, 'AppName') then FAppName := val else if WideSameText(nm, 'MSIFileName') then FMSIFileName := SetupInfPath + val else if WideSameText(nm, 'TitleImage') then FTitleImageFileName := SetupInfPath + val else if WideSameText(nm, 'MSIOptions') then FMSIOptions := val // I3126 else if WideSameText(nm, 'StartWithConfiguration') then FStartWithConfiguration := StrToBoolDef(val, False) else if WideSameText(nm, 'StartDisabled') then FStartDisabled := StrToBoolDef(val, False); end else if FInPackages then begin if System.SysUtils.FileExists(SetupInfPath + Names[i]) then begin pack := FPackages.FindById(ChangeFileExt(Names[i], ''), True); packLocation := TInstallInfoPackageFileLocation.Create(iilLocal); pack.Locations.Add(packLocation); packLocation.Path := SetupInfPath + Names[i]; res := TRegEx.Match(ValueFromIndex[i], '/^(.+) ((\d+\.)*\d+)$'); if res.Success then begin packLocation.Name := res.Groups[1].Value; packLocation.Version := res.Groups[2].Value; end else begin packLocation.Name := ValueFromIndex[i]; packLocation.Version := '0'; end; end; end else if FInStrings then FStrings.Add(Strings[i]); end; if (FVersion = '') then raise EInstallInfo.Create('setup.inf is corrupt (code 1). Setup cannot continue.'); if System.SysUtils.FileExists(FMSIFileName) then // I3476 begin location := TInstallInfoFileLocation.Create(iilLocal); location.Path := FMSIFileName; location.Version := FVersion; FMsiLocations.Add(location); end; if not System.SysUtils.FileExists(FTitleImageFileName) then // I3476 FTitleImageFileName := ''; finally Free; end; end; procedure TInstallInfo.LocatePackagesFromParameter(const Param: string); var n: Integer; opt, res: TArray; id, FBCP47: string; begin res := TRegEx.Split(Param, ','); for n := 0 to Length(res) - 1 do begin opt := TRegEx.Split(res[n], '='); id := opt[0].ToLower.Trim; if Length(opt) > 1 then FBCP47 := opt[1].ToLower.Trim else FBCP47 := '_'; FPackages.Add(TInstallInfoPackage.Create(id, FBCP47)); end; end; procedure TInstallInfo.LocatePackagesAndTierFromFilename(Filename: string); const SKeymanSetupPrefix = 'keyman-setup'; SKeymanSetup_Alpha = SKeymanSetupPrefix+'-'+TIER_ALPHA; SKeymanSetup_Beta = SKeymanSetupPrefix+'-'+TIER_BETA; SKeymanSetup_Stable = SKeymanSetupPrefix+'-'+TIER_STABLE; var n: Integer; res: TArray; p, id, FBCP47: string; m: TMatch; begin // Get just the base filename Filename := ExtractFileName(ChangeFileExt(Filename, '')); // Strip " (1)" appended for multiple downloads of same file by most browsers m := TRegEx.Match(Filename, '^('+SKeymanSetupPrefix+'.+) \(\d+\)$'); if m.Success then Filename := m.Groups[1].Value; // Look for our recognised pattern of keyman-setup.package_id.bcp47... res := TRegEx.Split(Filename, '\.'); if (Length(res) < 1) or not res[0].ToLower.StartsWith(SKeymanSetupPrefix) then // No packages embedded in filename, or not a recognised filename pattern Exit; // Look for an embedded tier in the filename, if not set, use default p := res[0].ToLower; if p.Equals(SKeymanSetup_Stable) then FTier := TIER_STABLE else if p.Equals(SKeymanSetup_Beta) then FTier := TIER_BETA else if p.Equals(SKeymanSetup_Alpha) then FTier := TIER_ALPHA; n := 1; while n < Length(res) do begin id := res[n].ToLower.Trim; Inc(n); if n < Length(res) then FBCP47 := res[n].ToLower.Trim else FBCP47 := '_'; Inc(n); FPackages.Add(TInstallInfoPackage.Create(id, FBCP47)); end; end; procedure TInstallInfo.LocatePackagesInPath(const path: string); var f: TSearchRec; id, p: string; pack: TInstallInfoPackage; packLocation: TInstallInfoPackageFileLocation; lang: TPackageKeyboardLanguage; iipl: TInstallInfoPackageLanguage; begin p := IncludeTrailingPathDelimiter(path); if FindFirst(p+'*'+Ext_PackageFile, 0, f) = 0 then begin repeat if SameText(ExtractFileExt(f.Name), Ext_PackageFile) then begin packLocation := TInstallInfoPackageFileLocation.Create(iilLocal); if GetPackageMetadata(p + f.Name, packLocation.LocalPackage) then begin id := ChangeFileExt(f.Name, '').ToLower; pack := FPackages.FindById(id, True); packLocation.Size := FileSizeByName(p + f.Name); packLocation.Name := packLocation.LocalPackage.Info.Desc[PackageInfo_Name]; packLocation.Version := packLocation.LocalPackage.Info.Desc[PackageInfo_Version]; packLocation.Path := p + f.Name; if packLocation.LocalPackage.Keyboards.Count = 1 then begin // We only populate the language list if there is a single keyboard // in the package. Otherwise, we let Keyman install default lang // for each keyboard in the package. for lang in packLocation.LocalPackage.Keyboards[0].Languages do begin iipl := TInstallInfoPackageLanguage.Create(lang.ID, lang.Name); packLocation.Languages.Add(iipl); end; end; pack.Locations.Add(packLocation); end else packLocation.Free; end; until FindNext(f) <> 0; System.SysUtils.FindClose(f); end; end; function TInstallInfo.GetPackageMetadata(const KmpFilename: string; p: TPackage): Boolean; var zip: TZipFile; ms: TStream; h: TZipHeader; begin try zip := TZipFile.Create; try zip.Open(KmpFilename, zmRead); if zip.IndexOf('kmp.json') >= 0 then begin // Read kmp.json zip.Read('kmp.json', ms, h); try ms.Position := 0; p.LoadJSONFromStream(ms); finally ms.Free; end; Exit(True); end else if zip.IndexOf('kmp.inf') >= 0 then begin // Read kmp.inf (phooey) zip.Extract('kmp.inf', FTempPath + 'kmp.inf', False); p.FileName := FTempPath + 'kmp.inf'; p.LoadIni; Exit(True); end else // Not a valid package Exit(False); finally zip.Free; end; except on E:EZipException do // TODO: log this to the logfile, but don't show Exit(False); end; end; function TInstallInfo.Text(const Name: TInstallInfoText; const Args: array of const): WideString; begin Result := WideFormat(Text(Name), Args); end; function TInstallInfo.CheckMsiAndPackageUpgradeScenarios: Boolean; begin // Keyman install scenarios // 1. Keyman latest version already installed --> nothing to do // 2. Keyman outdated version already installed --> optional upgrade // 3. Keyman not installed --> install required // Keyman source scenarios // A. Available locally, latest version (or working offline) // B. Available locally, outdated version; online update available // C. Not available locally, online update available // D. Not available (working offline) // Matrix: // 1ABCD: Nothing to do // 2A: offer to upgrade from local // 2B: offer to upgrade from online (don't offer local update even if newer than current install, just for simplicity?) // 2C: offer to upgrade from online // 2D: Nothing to do // 3A: require install // 3B: offer to install from online (if update not taken, require install from local) // 3C: require download // 3D: Fail FBestMsi := GetBestMsi; FIsInstalled := FInstalledVersion.Version <> ''; if not Assigned(FBestMsi) then // Matrix 123D FIsNewerAvailable := False else if FIsInstalled then // Matrix 12ABC FIsNewerAvailable := CompareVersions(FInstalledVersion.Version, FBestMsi.Version) > 0 else // Matrix 3ABC FIsNewerAvailable := True; Result := FIsInstalled or FIsNewerAvailable; end; { TInstallInfoPackage } constructor TInstallInfoPackage.Create(const AID: string; const ABCP47: string = ''); begin inherited Create; FLocations := TInstallInfoPackageFileLocations.Create; FID := AID; if ABCP47 <> '_' then FBCP47 := ABCP47; FShouldInstall := True; end; destructor TInstallInfoPackage.Destroy; begin FreeAndNil(FLocations); inherited Destroy; end; function TInstallInfoPackage.GetBestLocation: TInstallInfoPackageFileLocation; var location: TInstallInfoPackageFileLocation; begin if FLocations.Count = 0 then Exit(nil); Result := FLocations[0]; for location in FLocations do case CompareVersions(Result.Version, location.Version) of 0: if Result.LocationType = iilOnline then Result := location; // prefer local for same version 1: Result := location; end; end; { TInstallInfoPackageLanguage } constructor TInstallInfoPackageLanguage.Create(const ABCP47, AName: string); begin inherited Create; FBCP47 := ABCP47; FName := AName; end; { TInstallInfoPackageFileLocations } function TInstallInfoPackageFileLocations.LatestVersion( const default: string): string; var location: TInstallInfoPackageFileLocation; begin Result := default; for location in Self do if CompareVersions(Result, location.Version) > 0 then Result := location.Version; end; { TInstallInfoFileLocations } function TInstallInfoFileLocations.LatestVersion(const default: string): string; var location: TInstallInfoFileLocation; begin Result := default; for location in Self do if CompareVersions(Result, location.Version) > 0 then Result := location.Version; end; { TInstallInfoPackages } function TInstallInfoPackages.FindById(const id: string; createIfNotFound: Boolean): TInstallInfoPackage; begin for Result in Self do if SameText(id, Result.ID) then Exit; if createIfNotFound then begin Result := TInstallInfoPackage.Create(id); Add(Result); end else Result := nil; end; { TInstallInfoPackageFileLocation } constructor TInstallInfoPackageFileLocation.Create(ALocationType: TInstallInfoLocationType); begin inherited Create(ALocationType); FLanguages := TInstallInfoPackageLanguages.Create; FLocalPackage := TKmpInfFile.Create; end; destructor TInstallInfoPackageFileLocation.Destroy; begin FreeAndNil(FLanguages); FreeAndNil(FLocalPackage); inherited Destroy; end; function TInstallInfoPackageFileLocation.GetLanguageNameFromBCP47( const bcp47: string): string; var lang: TInstallInfoPackageLanguage; begin Result := ''; for lang in FLanguages do if SameText(lang.BCP47, bcp47) then Exit(lang.Name); end; function TInstallInfoPackageFileLocation.GetNameOrID(const id: string): string; begin if FName = '' then Result := id else Result := FName; end; { TInstallInfoFileLocation } constructor TInstallInfoFileLocation.Create( ALocationType: TInstallInfoLocationType); begin inherited Create; FLocationType := ALocationType; end; procedure TInstallInfoFileLocation.UpgradeToLocalPath(const ARootPath: string); begin Assert(FLocationType = iilOnline); Assert(FileExists(ARootPath + FPath)); FPath := ARootPath + FPath; FLocationType := iilLocal; end; // Pulled from IdGlobalProtocols so we don't have to import it // OS-independant version function FileSizeByName(const AFilename: string): Int64; var LHandle : THandle; LRec : TWin32FindData; begin Result := -1; LHandle := Winapi.Windows.FindFirstFile(PChar(AFileName), LRec); if LHandle <> INVALID_HANDLE_VALUE then begin Winapi.Windows.FindClose(LHandle); if (LRec.dwFileAttributes and Winapi.Windows.FILE_ATTRIBUTE_DIRECTORY) = 0 then begin Result := (Int64(LRec.nFileSizeHigh) shl 32) + LRec.nFileSizeLow; end; end; end; end.