(* Wolfram Language Package *)

(* Created by the Wolfram Workbench Apr 21, 2017 *)

BeginPackage["SystemInstall`"]  (*  give the PacletManager` needs here *)
(* Exported symbols added here with SymbolName::usage *)

System`SystemInstall::unknownsys = "The system `1` is not available to be installed.";
System`SystemInstall::invalidLegalInput = "Invalid response. Valid responses are Yes/No.";
System`SystemInstall::depCheck = "The system `1` could not be installed because `2`.";
System`SystemInstall::invalidOpt = "The setting for `1`, `2`, is invalid, `3`.";
System`SystemInstall::installFail = "SystemInstall failed to install the system `1`.";
System`SystemInstall::alreadyInstalled = "The system `1` is already installed.";
System`SystemInstall::nosilent = "The system `1` only supports interactive installation, use Interactive->True to install this software.";
System`SystemInstall::verifyFail = "The executable downloaded for system `1` has `2` and cannot be verified.";

Begin["`Private`"]
(* Implementation of the package *)

Needs["PacletManager`"]; (* this should be the second argument to BeginPackge above *)

(*this is necessary to prevent recursive autoloading of the System` symbols that the paclet manager sets up for us*)
(
	ClearAttributes[#,{Stub,Protected,ReadProtected}];
	Clear[#];
)&/@
	{
		"System`SystemInstall"
	}

(*this variable holds all the rules for how to install the registed softwares*)
$SystemInstallationRules = <||>;

$UpdatedSystems = {};

(*this is a short hand for what systems are supported to be installed*)
$SupportedSystems := Keys[$SystemInstallationRules]

(*only required things are URL and Message*)
(*URL is the url to download for the file*)
(*Message is the message to display in the gui when the user requests it*)
$RequiredSystemInstallationRules = {"URLCommand","Message"};

$OptionalSystemInstallationRules = <|
	(*this function is called with the executable downloaded and the desired location to install to*)
	(*it should start up the process*)
	(*and block until the installation has completed, returning the location of where it installed to if successful*)
	(*and $Failed if it didn't install*)
	"InteractiveInstallCommand"->(RunProcess[#1];If[FileExistsQ[#2],#2,$Failed]&),
	(*silent install supported q is whether or not the system supports a silent install without user interaction*)
	"SilentInstallSupportedQ"->False,
	(*silent install function is the same as the interactive, but it is supposed to run in the background*)
	(*not all systems will support this*)
	"SilentInstallCommand"->None,
	(*the InstalledQCommand checks whether or not the system has already been installed*)
	(*default installation locations are given by : FileNameJoin[{$UserBaseDirectory,"ApplicationData","SystemInstall",sysname}]*)
	(*but this could change at some point... in the future*)
	(*only argument is where it got installed to*)
	"InstalledQCommand"->FileExistsQ,
	(*the ExecutableSignatureInfo is what we expect the signature of the installer executable to be*)
	(*for example, on mac this is given by an association of the certificate chain*)
	(*this can be generated by taking the correctly signed executable and running SystemInstall`Private`macPkgParseSignatureData[file]["Certificates"] on mac*)
	(*or SystemInstall`Private`windowsExecutableSignatureData[file]["Certificates"]*)
	"ExecutableSignatureInfoCommand"->None,
	(*the FileHash is what we expect the hash of the file to be when we donwload it*)
	"FileHashCommand"->None,
	(*The PostInstallCommand is what command to run after the installation is done, i.e. to install additional packages etc.*)
	(*only argument is where it got installed to*)
	"PostInstallCommand"->None,
	(*the dependency check command can perform a check before installing the software to see if it is available*)
	(*for example if a system is windows only, etc.*)
	"DependencyCheckCommand"->{True,"Success"},
	(*the pacletname is for auto-updating of system install rules via paclets*)
	"PacletName"->None,
	(*aliases is a list of possible alternative names for this system*)
	"Aliases"->None
|>;




(*code for this mostly copied from ExternalEvaluate's registration system*)
RegisterSystemInstallation[sysname_,opts_?AssociationQ]:=Block[{},
	(
		(*confirm that the required options are present and no more*)
		If[Sort[Intersection[Keys[opts],$RequiredSystemInstallationRules]] === Sort[$RequiredSystemInstallationRules] &&
			Complement[Keys[opts],Join[Keys[$OptionalSystemInstallationRules],$RequiredSystemInstallationRules]] == {},
			(*THEN*)
			(*it's valid and can be used*)
			(
				(*get the elements not specified that need to use the default versions*)
				defaults = KeyTake[Complement[Keys[$OptionalSystemInstallationRules],Keys[opts]]] @ $OptionalSystemInstallationRules;

				(*now store the association in the $SystemInstallationRules*)
				$SystemInstallationRules[sysname] = Join[defaults,opts];

				(*TODO : enable the PacletInfo key so that systems can be registered using paclets and be updated as appropriate*)
				(*check the "PacletName" to see if it was specified as a string, else if it's the default*)
				(*value, then we need to replace it with SystemInstall_system*)
				If[$SystemInstallationRules[sysname,"PacletName"] =!= None,
					(*THEN*)
					(*it's not None, so we need to check if it needs to be populated*)
					(
						(*check if it's meant to be automatically populated*)
						If[MemberQ[$SystemInstallationRules[sysname,"PacletName"]]@{Default,Automatic,Inherited},
							(*THEN*)
							(*need to generate it as the default form*)
							$SystemInstallationRules[sysname,"PacletName"] = "SystemInstall_"<>sysname,
							(*ELSE*)
							(*it's been populated, make sure the spec is a string and the paclet exists*)
							If[!StringQ[$SystemInstallationRules[sysname,"PacletName"]]||
								PacletManager`PacletFind[$SystemInstallationRules[sysname,"PacletName"]] === {},
								(*THEN*)
								(*doesn't exist or invalid spec*)
								(
									(*TODO : issue message about this - we can't use it so just ignore *)
									(*the paclet updating issue*)
									$SystemInstallationRules[sysname,"PacletName"] = None
								)
								(*ELSE*)
								(*good to go*)
							]
						]
					)
					(*ELSE*)
					(*it's None, so we shouldn't check paclet updates for this driver*)
				]
			),
			(*ELSE*)
			(*invalid, either invalid options or not enough options present*)
			(
				$Failed
			)
		]
	)
]

If[TrueQ[$CloudEvaluation] && !TrueQ[Lookup[CloudSystem`KernelInitialize`$ConfigurationProperties, "AllowSystemInstallFunctionality"]],
(* Running in cloud environment, define dummy functions that tell the user this functionality is not yet available. *)
	System`SystemInstall[___] := (Message[General::cloudf, HoldForm@SystemInstall]; $Failed);
,
(* Else define as usual *)
	Options[System`SystemInstall] = {Interactive->True,VerifySecurityCertificates->True};
	System`SystemInstall[sysname_?StringQ,opts:OptionsPattern[]] := Block[
	{
		sys = sysname,
		userConfirm,
		installLoc
	},
	(
		(*check the system name*)
		If[!MemberQ[sys]@$SupportedSystems,
			(*THEN*)
			(*didn't find the system name directly, but it could be an alias - so we have to go searching through the aliases*)

			(
				(*attempt to do a paclet update*)
				updatePacletPlugin[sysname];
				(*now go check if this system is in supported systems*)
				sys = SelectFirstKey[MemberQ[sysname]@ #["Aliases"]& ] @ $SystemInstallationRules;
				If[!MissingQ[sys],
					(*THEN*)
					(*it's an alias for the system referenced by sys*)
					(
						(*save the real system name, because SelectFirstKey returns a Key[...] wrapper*)
						sys = First[sys];
					),
					(*ELSE*)
					(*didn't find the system - one last thing we can check is that this system was put onto the paclet server*)
					(*so we try to load the paclet*)
					(
						If[FailureQ[pacletSystemCheck[sysname]],
							(*THEN*)
							(*we failed to find the paclet on the servers to install, so we just fail*)
							(
								Message[System`SystemInstall::unknownsys,sysname];
								Return[$Failed]
							),
							(*ELSE*)
							(*we got the paclet, just reset sys to the call this function again*)
							(
								sys = sysname;
							)
						]
					)
				]
			)
		];

		installLoc = FileNameJoin[{$UserBaseDirectory,"ApplicationData","SystemInstall",sys}];

		(*check opts*)
		If[!MatchQ[OptionValue[Interactive],True|False|Automatic],
			(*THEN*)
			(*invalid option value*)
			(
				Message[
					System`SystemInstall::invalidOpt,
					Interactive,
					OptionValue[Interactive],
					"only True, False, or Automatic is supported."
				];
				Return[$Failed]
			)
		];

		If[!MatchQ[OptionValue[VerifySecurityCertificates],True|False|Automatic],
			(*THEN*)
			(*invalid option value*)
			(
				Message[
					System`SystemInstall::invalidOpt,
					VerifySecurityCertificates,
					OptionValue[VerifySecurityCertificates],
					"only True, False, or Automatic is supported."
				];
				Return[$Failed]
			)
		];

		(*if we were asked to do silent, i.e. interactive, check if that's supported*)
		If[OptionValue[Interactive] === False && !$SystemInstallationRules[sys,"SilentInstallSupportedQ"][],
			(*THEN*)
			(*system doesn't support silent installation*)
			(
				Message[System`SystemInstall::nosilent,sys];
				Return[$Failed]
			)
		];

		(*next we have to check if the system is already installed, in which case we message and return*)
		If[$SystemInstallationRules[sys,"InstalledQCommand"][installLoc],
			(*THEN*)
			(*already installed, so just return*)
			(
				Message[System`SystemInstall::alreadyInstalled,sys];
				Return[Null];
			)
		];

		(*next to actually install the system we have to ask the user if they are sure they want to install it*)
		If[
			If[$FrontEnd =!= Null,
				(*THEN*)
				(*have front end, can use a dialog window*)
				ChoiceDialog[
					$SystemInstallationRules[sys,"Message"],
					{"Install" -> True,"Do Not Install" -> False},
					WindowTitle -> sys<>" SystemInstall", WindowFloating -> True,
					WindowSize -> {700, 225}, WindowFrame -> "ModalDialog"
				],
				(*ELSE*)
				(*don't have front end, use textual prompt for permission to install*)
				(
					Print[$SystemInstallationRules[sys,"Message"]];
					While[True,
						(
							(*now take the user's input as a string with InputString*)
							input = InputString[];
							If[MemberQ[{"true","yes","false","no"},ToLowerCase[input]],
								(*THEN*)
								(*input is good, we can exit*)
								Break[],
								(*ELSE*)
								(*input is invalid, continue again, but issue a message first*)
								Message[System`SystemInstall::invalidLegalInput]
							]
						)
					];
					MemberQ[{"true","yes"},ToLowerCase[input]]
				)
			],
			(*THEN*)
			(*we're good to go install it, we have confirmation*)
			(
				(*first step is to check if we can install the software *)
				depCheck = $SystemInstallationRules[sys,"DependencyCheckCommand"][];
				If[First[depCheck],
					(*THEN*)
					(*we're good to go*)
					(
						(*now we have to download the file*)
						execFile = URLDownload@@MapAt[FileNameJoin[{$TemporaryDirectory,#}]&,$SystemInstallationRules[sys,"URLCommand"][],2];
						If[FailureQ[execFile],
							(*THEN*)
							(*failed to download*)
							Return[$Failed],
							(*ELSE*)
							(*downloaded - strip the File[] wrapper*)
							execFile = First[execFile]
						];


						(*now check the digital signature of that file to make sure it's appropriately signed*)
						(*falling back to checking the hash of the bytes if that's not specified*)
						If[$SystemInstallationRules[sys,"ExecutableSignatureInfoCommand"] === None,
							(*THEN*)
							(*fall back to using a file hash if that is specified*)
							(
								expectHash = $SystemInstallationRules[sys,"FileHashCommand"];
								If[expectHash =!= None && expectHash[] =!= FileHash[execFile],
									DeleteFile[execFile];
									Message[System`SystemInstall::verifyFail,sys,"an invalid FileHash"];
									Return[$Failed]
								]
							),
							(*ELSE*)
							(*there was a digital signature specified, so we have to handle that*)
							(
								If[MemberQ[{True,Automatic,Default}]@OptionValue[VerifySecurityCertificates],
									(*THEN*)
									(*check security certificates*)
									(
										Switch[$SystemID,
											"Windows"|"Windows-x86-64",
											(*use the windows signature functions*)
											(
												signature = windowsExecutableSignatureData[execFile];
											),
											"MacOSX-x86-64",
											(*use the mac osx pkgutil functions*)
											(
												signature = macPkgParseSignatureData[execFile];
												If[signature["Status"] =!= "signed by a certificate trusted by Mac OS X" ||
													!assocCompare[signature["Certificates"],$SystemInstallationRules[sys,"ExecutableSignatureInfoCommand"][]],
													(*THEN*)
													(*the digital signature test failed - issue message and fail*)
													Message[System`SystemInstall::verifyFail,sys,"invalid certificates"];
													Return[$Failed];
													(*ELSE*)
													(*test passed can continue onto running the executable*)
												]
											)
										]
									)
									(*ELSE*)
									(*user specifically requested not to verify security certificates so we just skip this step*)
								]
							)
						];


						(*now based on the interactive option run the file*)
						actualInstallLoc = If[(TrueQ[#] || #===Automatic)& @ OptionValue[Interactive],
							(*THEN*)
							(*run it interactively*)
							(
								$SystemInstallationRules[sys,"InteractiveInstallCommand"][execFile,installLoc]
							),
							(*ELSE*)
							(*use silent option*)
							(
								$SystemInstallationRules[sys,"SilentInstallCommand"][execFile,installLoc]
							)
						];

						(*now that we have run the installer, we can delete the executable installer file*)
						DeleteFile[execFile];

						(*confirm that the install command didn't return a failure*)
						If[FailureQ[actualInstallLoc],
							Message[System`SystemInstall::installFail,sys];
							Return[$Failed]
						];

						(*now run the post install command*)
						$SystemInstallationRules[sys,"PostInstallCommand"][actualInstallLoc];

						(*now check that the installation works*)
						If[!$SystemInstallationRules[sys,"InstalledQCommand"][actualInstallLoc],
							(*THEN*)
							(*failed to install somehow*)
							(
								Message[System`SystemInstall::installFail,sys];
								$Failed
							)
						]
					),
					(*ELSE*)
					(*failed - issue message*)
					(
						Message[System`SystemInstall::depCheck,sys,Last[depCheck]];
						$Failed
					)
				]
			),
			(*ELSE*)
			(*user said no, return $Failed*)
			(
				$Failed
			)
		]
	)];
];(* End bracket of section disabled on cloud. *)

(*update paclet plugin will perform an update for the specified system, using the specified paclet name*)
(*however, we don't accept Missing[], as when this is called at the top of functions like*)
(*etc. we could get called before knowing that the specified language doesn't exist*)
(*as such the second argument, which is either a direct string and should be used, None, which means*)
(*the system exists, but doesn't have an associated paclet, and Missing when the language doesn't exist*)
updatePacletPlugin[sys_?StringQ,sysPacName_:Automatic]:=Block[
	{pacname = If[sysPacName===Automatic,$SystemInstallationRules[sys,"PacletName"],sysPacName]},
	(
		(*do the update for the paclet*)
		doPacletUpdate[pacname];
		(*mark this paclet as updated in this session - note we only do this when we know that*)
		(*the language's paclet name isn't Missing, as if it's missing, then this system doesn't exist*)
		If[!MissingQ[pacname],AppendTo[$UpdatedSystems,sys]];
	)
]

(*function that actually performs the paclet update for a provided paclet*)
doPacletUpdate[sysPacName_? !MissingQ]:=
	(*some systems might not have a paclet, i.e. ones that are just used dynamically in a kernel session*)
	(*when prototyping, these shouldn't cause a paclet check, etc.*)
	If[sysPacName =!= None,
		(*THEN*)
		(*we proceed with checking for updates to the paclet*)
		(
			(*update the paclet*)
			getPacletWithProgress[sysPacName];
			(*now load the paclet's system*)
			Get[PacletManager`PacletResource[sysPacName, "System"]];
		)
		(*ELSE*)
		(*this driver doesn't have an associated paclet, so don't do anything*)
	];

(*paclet system check looks for an system install plugin paclet for a particular specified system*)
(*because this is an unknown language, we assume that the name of the plugin paclet is SystemInstall_sys*)
pacletSystemCheck[sys_?StringQ]:=Block[{pacname = "SystemInstall_"<>sys},
	(
		(*make sure at least one version of the paclet exists*)
		If[Length[PacletCheckUpdate[pacname, "UpdateSites" -> True]] > 0,
			(*THEN*)
			(*we were able to find a paclet for this language and should update it*)
			updatePacletPlugin[sys,pacname],
			(*ELSE*)
			(*didn't find one - return $Failed to indicate we didn't find one*)
			$Failed
		]
	)
]


(*now that we've set everything up, search for all locally installed system install plugin paclets*)
(*note that we don't do any searching on the paclet server, just local ones at startup*)
(*remote ones are loaded lazily, i.e. only searched for when needed/requested*)
(*so to trigger loading of some new system, you have to actually use it with SystemInstall*)
(*or manually download/install the paclet yourself*)
loadInstalledSystemInstallPlugins[] := With[
	{
		(*find all SystemInstall_* plugins*)
		localPaclets = First /@ GatherBy[PacletManager`PacletFind["SystemInstall_*"], #["Name"] &]
	},
	(
		(*get all the system files to register all the systems for the paclets we have locally*)
		Get/@Flatten[PacletManager`PacletResource[#, "System"]& /@ localPaclets];
		(*also set that we don't have any systems we've updated yet in this kernel session*)
		$UpdatedSystems = {};
	)
]

loadInstalledSystemInstallPlugins[]




(*helper function for selecting a key out of an association where they key's value matches a predicate*)
SelectFirstKey[assoc_,pred_,default_:Missing["NotFound"]]:=Block[
	{pos = LengthWhile[assoc,Not@*pred]},
	If[pos === Length[assoc],
		(*THEN*)
		(*we didn't find it, so return the default*)
		(default),
		(*ELSE*)
		(*we did find it, so return that key, incrementing the position, because LengthWhile counts the number of things before this element that are false for the predicate*)
		(Key[Keys[assoc][[pos+1]]])
	]
]

(*operator form*)
SelectFirstKey[pred_][assoc_]:=SelectFirstKey[assoc,pred]


(*function that takes in a pkg file path and will use osx utilities to get the certificate / digital signing information*)
macPkgParseSignatureData[file_?StringQ] := Block[
	{status, certchain, output},
	If[file =!= "" && FileExistsQ[file],
		(*THEN*)
		(*the file exists and we can check the signature of it using pkgutil*)
		(
			{{status}, certchain} = TakeDrop[
				(*the first line is filename, so we drop that*)
				Rest@StringSplit[
					(*to run the certificate verification process, we use pkgutil with the --check-signature argument*)
					RunProcess[
						{
							"pkgutil",
							"--check-signature",
							ExpandFileName@file
						},
						(*if there are errors, they are put out onto StandardError, so we don't have to worry about invalid files*)
						(*or parse errors, etc. and can just focus on StandardOutput which will always be formatted in a specific way for the *)
						(*first 2 lines for us to parse*)
						"StandardOutput"
					],
					"\n"
				],
				(*the first line (after the filename) is the status line, the rest is either empty if error or the certificate chain entries*)
				1
			];
			Association[
				{
					(*the status string will always exist, so parse that out, splitting the string by :*)
					Rule @@ StringTrim /@ StringSplit[status, ":"],
					If[Length[#] > 0,
						(*if there is at least 1 line more in the output, that means we have a cert chain that was output*)
						"Certificates" ->
							(*at the end of parsing, we have an assoc with the keys as the developer cert names going to the *)
							(*line that is output with the SHA1 hash information*)
							(*so this bit of code parses that line into the actual number for the hash*)
							FromDigits[#, 16] &@*StringDelete[WhitespaceCharacter]@*StringDelete["SHA1 fingerprint:"] /@
								(*this builds up the assoc by deleting up to the point*)
								<|Rule @@@ (StringTrim /@ MapAt[
										(*each cert entry starts with the number in the chain, but we already know the chain by the ordering*)
										(*so we can strip off this number*)
										StringDelete[StartOfString ~~ DigitCharacter .. ~~ "."],
										StringTrim[StringSplit[#, "\n"]],
										1
										(*this next line takes all of the rest of the lines of the output and trims all the whitespace off of them*)
										(*then riffles them back together into a single string with newlines interspersed*)
										(*then splits it by the 77 "-" that mac outputs in between the entries*)
										(*TODO : check if this is 77 on other OSX versions other than 10.11*)
									] & /@ StringSplit[StringRiffle[StringTrim /@ Rest[#], "\n"], Repeated["-", 77]])
								|>,
						(*ELSE*)
						(*no cert chain, as status must contain the error, so just make this If go away*)
						Nothing
					]&@certchain
				}
			]
		),
		$Failed
	]
]

windowsExecutableSignatureData[file_?StringQ] := Block[{},($Failed)];


assocCompare[assoc1_?AssociationQ, assoc2_?AssociationQ] :=
 Block[{keys1 = Keys[assoc1], keys2 = Keys[assoc2],
   vals1 = Values[assoc1], vals2 = Values[assoc2]},
  Complement[keys1, keys2] === {} &&

   Complement[keys2, keys1] === {} &&

   Complement[vals1, vals2] === {} &&

   Complement[vals2, vals1] === {}
  ]



(*reprotect all the System` symbols again*)

SetAttributes[#,{ReadProtected,Protected}]&/@
	{
		"System`SystemInstall"
	}

End[]

EndPackage[]
