Skip to content

Instantly share code, notes, and snippets.

@sancarn
Last active October 25, 2020 21:11
Show Gist options
  • Save sancarn/cbd00077e850e77fe1c681729ad2afc7 to your computer and use it in GitHub Desktop.
Save sancarn/cbd00077e850e77fe1c681729ad2afc7 to your computer and use it in GitHub Desktop.

Infoworks ICM - Ruby Script Injection

Ever since I found the ruby scripting language been using it to extend existing functionality in ICM. However one thing which has struck me time and time again is the, almost, half-baked method of executing ruby scripts.

There are 4 options to execute a ruby scripts:

  1. Execute them from a ruby addon.
  2. Execute them from the user custom action menu.
  3. Execute them from the shared custom action menu.
  4. Execute them through the "run ruby script" menu item.

Number 1 is great but is limited to only 10 scripts at a time. The addons menu is also hidden away in a menu, completely out of site from the user, restricting how useful it can be. Numbers 2 and 3 are really easy to use however they require the user to set them up manually... You used to be able to dynamically setup these buttons for users, while using stand alone databases, however it seems Innovyze is phasing these out. Number 4 is decent, but once again it is hidden in a menu restricting its usefulness.

What we ideally need is a way to make custom GUIs outside of ICM, and then be able to interact with ICM directly. Custom GUIs can be set up to give users the ability to setup job specific tools full of their own personal ruby scripts. But in order to create these GUIs we need to be able to talk to ICM. We need ruby script injection.

Theory

Imagine the following ruby script:

require 'win32ole'

class Sandbox
  def Sandbox.new()
	return binding
  end
end

invoker = WIN32OLE.connect("{5c172d3c-c8bf-47b0-80a4-a455420a6911}")
code = invoker.scripts[$$]
mode = invoker.modes[$$]

invoker.rbActive($$)
  case mode 
    when 0
      Sandbox.new.eval(code,__FILE__,__LINE__)
    when 1
      eval(code,binding,__FILE__,__LINE__)
    else
      box = "box" + mode.to_s
      $boxes ||= {}
      $boxes[box] ||= Sandbox.new
      $boxes[box].eval(code,__FILE__,__LINE__)
  end
invoker.rbClosing($$)

When this ruby script is executed, what does it do? Let's take a look, step by step

  1. We connect to a custom object by it's GUID.
  2. We grab some data from an array of scripts from the invoker. Note: We use the procID to identify that instance of ICM.
  3. We grab a mode id from an array of modes from the invoker.
  4. We execute a function provided by invoker, rbActive(), to tell the invoker that the ruby script has been received and is being processed.
  5. We evaluate the code in a box.
  6. We execute a function provided by invoker, rbClosing(), to tell the invoker that the ruby script has finished executing and ICM will soon be ready to take new instructions.

With these steps we can easily see how we could potentially setup a means of communication between ICM and an invoker program. The question is, What would the invoker look like?

To experiment I've created an AHK class to perform the job of the invoker.

class Injector {
    
	modes      := {}
	scripts    := {}
	addonAddrs := {}
	addonPaths := {}
	callbacks  := {}
	
	activePIDs := {}
	disabledPIDs := {}
	
	;If install is default then overwrite file, else assume custom. Do not overwrite file.
	;If not installed correctly, install and ask for restart
	
	__makeInjector(path=0){
		if path = 0
			path = %A_Appdata%\Innovyze\WorkgroupClient\scripts\injector.rb
		
		rb = 
		(
			require 'win32ole'

			class Sandbox
			  def Sandbox.new()
				return binding
			  end
			end

			invoker = WIN32OLE.connect("{5c172d3c-c8bf-47b0-80a4-a455420a6911}")
			code = invoker.scripts[$$]
			mode = invoker.modes[$$]

			invoker.rbActive($$)
			  case mode 
				when 0
				  Sandbox.new.eval(code,__FILE__,__LINE__)
				when 1
				  eval(code,binding,__FILE__,__LINE__)
				else
				  box = "box" + mode.to_s
				  $boxes ||= {}
				  $boxes[box] ||= Sandbox.new
				  $boxes[box].eval(code,__FILE__,__LINE__)
			  end
			invoker.rbClosing($$)
		)
		
		FileDelete, %path%
		FileAppend, %rb%, %path%
	}
	
	
	__requestRestart(PID){
		if winexist("ahk_exe InnovyzeWC.exe") {
			Msgbox, ICM Requires a restart. Do you want to restart ICM now?
			if ErrorLevel {
				Msgbox, ICM will restart shortly
				WinClose, ahk_exe InnovyzeWC.exe
				RunWait, InnovyzeWC.exe /ICM
			} else {
				this.disabledPIDs[PID]:=True
			}
		}
	}
	
	;3 Possible cases:
	;	Case 1. User does not have a scripts.csv
	;		-> Install scripts.csv -> Ask for ICM to restart -> return 0
	;	Case 2. User has a scripts.csv, however scripts.csv does not contain Inject RubyScript key.
	;		-> Append line to scripts.csv -> Ask for ICM to restart -> return 0
	;	Case 3. User has a scripts.csv AND scripts.csv contains Inject RubyScript key.
	;		-> return 1 (can execute)
	__checkInstall(PID){
		;Define Ruby Path
		rbPath = %A_Appdata%\Innovyze\WorkgroupClient\scripts\injector.rb
		scriptsPath = %A_Appdata%\Innovyze\WorkgroupClient\scripts\scripts.csv
		rbContent := "Inject RubyScript, injector.rb`n"
		
		;Check if already disabled
		if this.disabledPIDs[PID] {
			this.__requestRestart()
			return 0
		}
		
		;If addon has already been evaluated jump straight to execution
		;Note each instance of ICM may have it's own address for ICMInject.rb
		;for this reason we store that data in a dictionary object 'addonAddrs'.
		if (!this.addonAddrs[PID]){
			;Does scripts.csv exist?
			if fileexist(scriptsPath){	;if scripts.csv exists
				;Case 2 & 3
				Loop, read, %scriptsPath% 
				{
					index = A_Index + 1
					If RegexMatch(A_LoopReadLine,"i)Inject RubyScript\s*,\s*(.+)",m){
						;Case 3
						;Index has been found
						this.addonAddrs[PID] := index
						this.addonPaths[PID] := m1
						if this.addonPaths[PID] = "injector.rb" 
							this.__makeInjector()
						return 1
					} else {
						RegexMatch(A_LoopReadLine,"i)Inject RubyScript\s*,\s*(.+)",m)
					}
				}
				
				;Case 2
				;If we get here then scripts.csv must not contain injector.rb
				;So let's add it, ask for a restart and return 0
				FileAppend, %rbContent%, %scriptsPath%
				
				;Create injector script
				this.__makeInjector()
				this.__requestRestart(PID)
				return 0
			} else {
				;Case 1
				FileCreateDir, %scriptsPath%
				content := "Inject RubyScript, injector.rb`n"
				FileAppend, %rbContent%, %rbPath%
				this.__makeInjector()
				this.__requestRestart(PID)
				return 0
			}
		} else {
			if this.addonPaths[PID] = "injector.rb"
				this.__makeInjector()
			return 1
		}
	}
	
	__callAddon(id){
		;ID = 1:  35080
		;ID = 2:  35081
		;ID = 3:  35082
		; ...
		wParam := 35080 + id - 1
		PostMessage, %WM_COMMAND%,%wParam%,0,,ahk_exe InnovyzeWC.exe
	}
	
	execute(rb,mode:=0,PID:=0){
		if !this.__checkInstall(PID)
			return 0
		
		;If PID == 0, use most recent PID
		if PID=0
			WinGet, PID, PID, ahk_exe InnovyzeWC.exe
		
		;Setup Interop parameters for given process
		scripts[PID] := rb
		modes[PID]   := mode
		
		this.__callAddon(addonAddrs[PID])
		
		return 1
	}
	
	executeFile(file,mode:=0,PID:=0){
		ruby=load('%file%')
		return this.execute(ruby,mode,PID)
	}
	
	;Event -  Active
	rbActive(pid){
		activePIDs[pid] := 1
		if callbacks[pid]
			callbacks[pid]("running")
	}
	
	;Event - Closing
	rbClosing(pid){
		activePIDs[pid] := 0
		if callbacks[pid]
			callbacks[pid]("closing")
	}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment