Skip to content

Portable install of Nodel and App Launcher on a blank Windows client

Justin Parker edited this page Jan 26, 2021 · 11 revisions

STEP 1 - NAME YOUR COMPUTER

  • use a sensible naming convention

STEP 2 - GET A COPY OF THE BOOTSTRAPPER

A working nodel-init-bootstrap.bat online version (supporting online dependencies) can be used from here for convenience but it is strongly recommended you host your own "on-prem" landing content (info further below).

Pick from one of four convenient ways:

  • use the console to launch:
mkdir \Nodel
cd \Nodel
curl https://gist.githubusercontent.com/justparking/a2e7b7e39160897d135e45e0f95b867d/raw/nodel-init-bootstrap.bat -o nodel-init-bootstrap.bat
nodel-init-bootstrap.bat
  • OR use notepad.exe, copy-n-paste text and create nodel-init-bootstrap.bat
  • OR copy the batch file using standard Windows Explorer² methods remembering to Unblock if necessary (right-click & see Properties)
  • OR launch via network share or downloaded and launch via browser, e.g. http://NODEL_SERVER:8088/nodes/landing/nodel-init-bootstrap.bat

NOTE:

  • Place the batch file in intended Nodel home directory, e.g. C:\Nodel\nodel-init-bootstrap.bat might be standard

STEP 3 - LAUNCH

  • double-click batch file OR run from command prompt
  • if Shell Swap required, launch with Admin rights, e.g. Run as administrator
  • optional If third-party application is known, use first argument as entry-point, e.g. > nodel-init-bootstrap.bat D:\MyFunkyContent\MyFunkyContent.exe
    • NOTE: argument is optional. App Launcher config can be done at any time post-installation.

Benefits

  1. Fast, low impact, portable installer with useful feedback
  2. Centralised custom installation
  3. Centralised hosting of any binary dependencies (Internet not required)
  4. Version management (effectively auto-update)
  5. Highly customisable

Assumptions

Blank Windows 10 Installation, nothing else.

  • For local deployment, Internet not required

General operation and outcomes

  1. downloads¹ any targeted Java JDK version
  2. downloads¹ any targeted Nodel version
  3. downloads¹ any other any targeted dependencies
  4. Windows PC node created and intelligently named e.g. MM-2-07 - Windows PC (Intel Core i7-7700HQ CPU 2.80GHz by GIGABYTE)
  5. Application Launcher node created and intelligently named
  6. Launch shortcut added to Start Menu/Startup
  7. Explorer Shell swapped (using WINLOGON/Shell registry)
  8. other steps can be easily added

Just one tiny batch file, nodel-init-bootstrap.bat, is required that bootstraps a much larger installation program/script.

That bootstrapper is hosted on a Nodel server called nodel-init-bootstrap.bat, conveniently hosted via a reasonably permanent node, say Landing on a port, say 8088.

Why a batch file?

Convenience, portability, simplicity of operation and understandability.


¹ Large downloads will be cached

² Any familiar method applies, e.g. via USB Drive, Remote Desktop, VNC, TeamViewer, etc.


Example "on-prem" Landing node content:

Adjust top constant(s) to suit

  • For example landing-host/nodes/Landing/content should host:

    • nodel-init-bootstrap.bat - see below
    • NodelInit.cs - see below
    • 7za920.zip - from here
    • OpenJDK8U-jdk_x64_windows_hotspot_8u242b08.zip - see here
    • nodelhost-dev-2.2.1-rev425.jar - see here
    • ComputerControllerRecipe.py - from here
    • ComputerController.cs - from here
    • AppLauncherRecipe.py - from here
  • Example nodel-init-bootstrap.bat

@echo off
set BOOTSTRAP_URI=http://172.16.80.27:8088/nodes/Landing/NodelInit.cs

set REV=15
rem This bootstraps the centrally managed NodelInit binary installer.

set TMPFOLDER=tmp
set SRC=%TMPFOLDER%\tmp1.cs

cd /D "%~dp0"
echo batch: START (rev. %REV%)
mkdir %TMPFOLDER% 2> NUL
echo using System; > %SRC%
echo using System.Collections.Generic; >> %SRC%
echo using System.IO; >> %SRC%
echo using System.Net; >> %SRC%
echo using System.Web; >> %SRC%
echo namespace NodelInitBootstrap { >> %SRC%
echo     class Program { >> %SRC%
echo         static void Main(string[] args) { >> %SRC%
echo             System.Net.ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; >> %SRC%
echo             var uri = new Uri("%BOOTSTRAP_URI%"); >> %SRC%
echo             Console.WriteLine("nodel-init-bootstrap: start (rev. " + %REV% + ")"); >> %SRC%
echo             string[] segments = uri.Segments; >> %SRC%
echo             string filename = "%TMPFOLDER%\\tmp2.cs"; >> %SRC%
echo             var req = WebRequest.Create(uri); >> %SRC%
echo             req.Method = "GET"; >> %SRC%
echo             req.Timeout = 10000; >> %SRC%
echo             File.Delete(filename); >> %SRC%
echo             var file = File.Create(filename); >> %SRC%
echo             using (var resp = req.GetResponse().GetResponseStream()) { >> %SRC%
echo                 byte[] buffer = new byte[10240]; >> %SRC%
echo                 for (; ; ) { >> %SRC%
echo                     int bytesRead = resp.Read(buffer, 0, buffer.Length); >> %SRC%
echo                     if (bytesRead ^<= 0) >> %SRC%
echo                         break; >> %SRC%
echo                     file.Write(buffer, 0, bytesRead); >> %SRC%
echo                 } >> %SRC%
echo             } >> %SRC%
echo             file.Close(); >> %SRC%
echo         } >> %SRC%
echo     } >> %SRC%
echo } >> %SRC%
%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe /nologo /reference:C:\Windows\Microsoft.NET\Framework\v4.0.30319\System.IO.Compression.FileSystem.dll  -out:%TMPFOLDER%\_nodel-init-bootstrap.exe %SRC%
rem No error checking, leaving as best effort
rem if NOT ERRORLEVEL 0 goto FAILURE
rem if ERRORLEVEL 1 goto FAILURE

%TMPFOLDER%\_nodel-init-bootstrap.exe %*
rem if NOT ERRORLEVEL 0 goto TRYANYWAY
rem if ERRORLEVEL 1 goto TRYANYWAY

%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe /nologo /reference:C:\Windows\Microsoft.NET\Framework\v4.0.30319\System.IO.Compression.FileSystem.dll -out:nodel-init.exe %TMPFOLDER%\tmp2.cs
rem if NOT ERRORLEVEL 0 goto FAILURE
rem if ERRORLEVEL 1 goto FAILURE

:TRYANYWAY

nodel-init.exe %*
if NOT ERRORLEVEL 0 goto FAILURE
if ERRORLEVEL 1 goto FAILURE

echo batch: success
goto FINISHED

:FAILURE
echo batch: failure

:FINISHED
echo batch: finished
  • Example NodelInit.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Text;
using System.Runtime.InteropServices.ComTypes;
using System.Diagnostics;
using System.Threading;
using System.Management;
using Microsoft.Win32;

// Compiles to nodel-init.exe
//
// Please adjust constants to suit environment.
//
// This program must fit in a single file.
// Some coding styles not adhered to for the sake of brevity.
//
// HISTORY
// 2020-07-21 21 JP: First time in Nodel wiki
//

namespace NodelInit
{
    class Program
    {
        readonly int REV = 21;
        
        static string INSTALLDOWNLOAD_PREFIX = "http://172.16.80.27:8088/nodes/Landing/";

        string BOOTSTRAP_BATCH_FILE = ".\\nodel-init-bootstrap.bat";

        string DOWNLOADPREFIX_7Z = INSTALLDOWNLOAD_PREFIX; // "https://www.7-zip.org/a/";
        string DOWNLOADNAME_7Z = "7za920.zip";
        string INSTALL_7ZIP = ".\\bin\\7-Zip";

        string DOWNLOADPREFIX_JDK = INSTALLDOWNLOAD_PREFIX; // "https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/jdk8u242-b08/";
        string DOWNLOADNAME_JDK_64 = "OpenJDK8U-jdk_x64_windows_hotspot_8u242b08.zip";
        string DOWNLOADNAME_JDK_32 = "OpenJDK8U-jdk_x86-32_windows_hotspot_8u242b08.zip";
        string INSTALL_JDK = ".\\bin\\jdk8u232-b09";
        string JAVA_EXE_PATH; // init in Launch
        
        string DOWNLOADPREFIX_NODELJAR = INSTALLDOWNLOAD_PREFIX; // "https://github.com/museumsvictoria/nodel/releases/download/v2.2.1.425/";
        string DOWNLOADNAME_NODELJAR = "nodelhost-dev-2.2.1-rev425.jar";
        string INSTALL_NODELJAR = ".\\bin";

        string DOWNLOAD_COMPUTER_RECIPE_RAW = INSTALLDOWNLOAD_PREFIX + "ComputerControllerRecipe.py";
        string DOWNLOAD_COMPUTER_CONTROLLER_RAW = INSTALLDOWNLOAD_PREFIX + "ComputerController.cs";
        
        string DOWNLOAD_APP_RECIPE_RAW = INSTALLDOWNLOAD_PREFIX + "AppLauncherRecipe.py";

        string DOWNLOAD_FOLDER = "downloads";

        string SEVENZ_EXE;
        string PROG_DATA_PATH;
        string APP_DATA_PATH;

        // the above are set in Launch()

        [STAThread]
        static void Main(string[] args)
        {
            // required for sites that require TLS2 for HTTPS
            ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;

            int closeIn = 15;

            try
            {
                Program program = new Program();
                program.Launch(args);

            }
            catch (Exception exc)
            {
                Log("");
                Log("");
                Log("errors - \"{0}\"", exc.Message);
                Log("");
                Log("");
                Log("");
                Log("");
                Log(exc.ToString());

                closeIn = 100;
            }

            Console.WriteLine("closing in {0} seconds...", closeIn);
            Thread.Sleep(closeIn * 1000);
        }

        void Launch(string[] args)
        {
            string currentFolder = Directory.GetCurrentDirectory();
            bool is64 = Environment.Is64BitOperatingSystem;
            PROG_DATA_PATH = Environment.GetEnvironmentVariable("ProgramData"); // for Start Menu -> Start
            APP_DATA_PATH = Environment.GetEnvironmentVariable("AppData");      // also Start Menu -> Start
            SEVENZ_EXE = Path.Combine(PROG_DATA_PATH, "7-Zip\\7z.exe");

            Log("nodel-init for ACMI rev. {0}, dotNet:{1}, currentfolder:{2} is64:{3}, args={4}", REV, Environment.Version, currentFolder, is64, string.Join(",", args));

            // Download Java jdk
            string jdkFilename = is64 ? DOWNLOADNAME_JDK_64 : DOWNLOADNAME_JDK_32;
            String jdkFilePath = GetOrDownloadURL(DOWNLOADPREFIX_JDK + jdkFilename, filename: jdkFilename);
            // ... and extract
            ExtractIfMissing(jdkFilePath, INSTALL_JDK);
            JAVA_EXE_PATH = FindFileOrFail("java.exe", INSTALL_JDK);

            // Download 7-Zip
            string sevenZipFilename = GetOrDownloadURL(DOWNLOADPREFIX_7Z + DOWNLOADNAME_7Z, filename: DOWNLOADNAME_7Z);
            // ... and extract
            ExtractIfMissing(sevenZipFilename, INSTALL_7ZIP);
            SEVENZ_EXE = Path.Combine(INSTALL_7ZIP, "7za.exe");

            // Download nodel JAR
            string nodelJarFilepath = GetOrDownloadURL(DOWNLOADPREFIX_NODELJAR + DOWNLOADNAME_NODELJAR, filename: DOWNLOADNAME_NODELJAR);
            // ... copy JAR
            string installJarPath = Path.Combine(INSTALL_NODELJAR, DOWNLOADNAME_NODELJAR);
            if (!File.Exists(installJarPath))
            {
                File.Copy(nodelJarFilepath, installJarPath);
            }

            // Update autolaunch shortcut in Start Menu/StartUp
            string bootstrapBatchFilePath = Path.GetFullPath(BOOTSTRAP_BATCH_FILE);
            IShellLink link = (IShellLink)new ShellLink();
            link.SetDescription("Nodel Bootstrap Launcher");
            link.SetPath(bootstrapBatchFilePath);
            IPersistFile file = (IPersistFile)link;

            // can do this for all users but this requires admin access:
            // String linkPath = Path.Combine(PROG_DATA_PATH, @"Microsoft\Windows\Start Menu\Programs\StartUp\nodelLaunch-shortcut.lnk");
            
            try {
               String linkPath = Path.Combine(APP_DATA_PATH, @"Microsoft\Windows\Start Menu\Programs\Startup\nodel-init-bootstrap_shortcut.lnk"); // e.g. C:\Users\Justin\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup
               file.Save(linkPath, false);
            } catch (Exception exc) {
               Log("Could not create traditional StartMenu link, ignoring.");
            }

            // create a computer node if necessary
            // (later versions of Nodel allow name templating)
            string computerNodeName = "$HOSTNAME - Windows PC ($CPUName $MaxClockSpeed $Model by $Manufacturer)";

            string computeNodePath = Path.Combine(string.Format(".\\nodes\\{0}", computerNodeName));

            if (!Directory.Exists(computeNodePath))
            {
                string computerScriptPath = GetOrDownloadURL(DOWNLOAD_COMPUTER_RECIPE_RAW, filename: "computerScript.py");
                Directory.CreateDirectory(computeNodePath);
                File.Copy(computerScriptPath, Path.Combine(computeNodePath, "script.py"));

                File.Copy(GetOrDownloadURL(DOWNLOAD_COMPUTER_CONTROLLER_RAW, filename: "ComputerController.cs"), Path.Combine(computeNodePath, "ComputerController.cs"));
            }

            // create application node if necessary
            string appLauncherNodeName = "$HOSTNAME - App Launcher";
            string appLauncherNodePath = Path.Combine(string.Format(".\\nodes\\{0}", appLauncherNodeName));
            if (!Directory.Exists(appLauncherNodePath))
            {
                string appLauncherScriptPath = GetOrDownloadURL(DOWNLOAD_APP_RECIPE_RAW, filename: "appLauncherScript.py");
                Directory.CreateDirectory(appLauncherNodePath);
                File.Copy(appLauncherScriptPath, Path.Combine(appLauncherNodePath, "script.py"));

                if (args.Length > 0)
                {
                    string appEntryPoint = args[0];
                    string appWorkingDir = Path.GetDirectoryName(appEntryPoint);

                    string configContents = "{'remoteBindingValues': { 'actions': {}, 'events': {}}, 'paramValues': { 'AppPath': '{0}', 'AppWorkingDir': '{1}', 'PowerStateOnStart': 'On'}}"
                        .Replace("{0}", appEntryPoint.Replace(@"\", @"\\"))
                        .Replace("{1}", appWorkingDir.Replace(@"\", @"\\"));

                    File.WriteAllText(Path.Combine(appLauncherNodePath, "nodeConfig.json"), configContents);
                }
            }

            // update registry if necessary // Computer\HKEY_LOCAL_MACHINE\
            var regKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon");

            // try update it if it doesn't exist - NOTE: will require admin rights
            Log("Updating 'Winlogon/shell' registry if possible");
            try
            {
                if (!regKey.GetValue("Shell").ToString().EndsWith(BOOTSTRAP_BATCH_FILE))
                {
                    regKey.Close();
                    regKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon", true);
                    regKey.SetValue("Shell", bootstrapBatchFilePath);
                }
            } catch (Exception)
            {
                Log("!! Could not update WinLogin / Shell registry; ignoring");
            }

            regKey.Close();

            Log("Launching Nodel and finishing...");
            Log("ENSURE FIREWALL RULES HAVE BEEN ACKNOWLEDGED IF NECESSARY");

            Process.Start(new ProcessStartInfo()
            {
                WorkingDirectory = Path.GetFullPath("."),
                Arguments = String.Format("-jar \"{0}\"", installJarPath),
                FileName = JAVA_EXE_PATH,
            });

            Log("Finished");
        }

        #region Convenience

        // (returns root folder)
        void ExtractIfMissing(string filename, string folder)
        {
            Log("extracting {0} into {1}", filename, folder);

            if (!File.Exists(filename))
                throw new FileNotFoundException();

            if (Directory.Exists(folder))
            {
                Log("{0} already present", folder);
                return;
            }

            // use a tmp directory to minimise half extractions on failure

            string tmpDir = folder + "_tmp";
            if (File.Exists(tmpDir))
                Directory.Delete(tmpDir, true);
            Directory.CreateDirectory(tmpDir);

            ZipFile.ExtractToDirectory(filename, tmpDir);

            // check if only one file is in root, use that as directory
            var rootDirs = Directory.GetDirectories(tmpDir);
            var rootFiles = Directory.GetFiles(tmpDir);
            if (rootDirs.Length == 1 && rootFiles.Length == 0)
            {
                Directory.Move(rootDirs[0], folder);
                // and delete left-over blank directory
                Directory.Delete(tmpDir);
            }
            else
            {
                Directory.Move(tmpDir, folder);
            }
        }

        String GetOrDownloadURL(String url, Dictionary<String, String> headers = null, String contentType = null, String post = null, String username = null, String password = null, String filename = null)
        {
            if (filename != null)
            {
                Log("downloading {0} from {1}", filename, url);

                // create a download directory
                Directory.CreateDirectory(DOWNLOAD_FOLDER);
            }

            var filepath = Path.Combine(DOWNLOAD_FOLDER, filename);

            var req = WebRequest.Create(url);
            // req.Timeout = 20000;

            String etag = null; //etag data

            string etagFilename = filepath + ".etag"; // if downloading
            if (File.Exists(etagFilename))
            {
                etag = File.ReadAllText(etagFilename);
                req.Headers["If-None-Match"] = etag;
            }

            if (headers != null)
            {
                foreach (var header in headers)
                    req.Headers.Add(header.Key, header.Value);
            }

            if (username != null)
            {
                String encoded = System.Convert.ToBase64String(System.Text.Encoding.GetEncoding("ISO-8859-1").GetBytes(username + ":" + password));
                req.Headers.Add("Authorization", "Basic " + encoded);
            }

            if (String.IsNullOrEmpty(post))
            {
                req.Method = "GET";
            }
            else
            {
                req.Method = "POST";

                if (contentType == null)
                    req.ContentType = "application/x-www-form-urlencoded";
                else
                    req.ContentType = contentType;

                var reqStream = req.GetRequestStream();
                StreamWriter sw = new StreamWriter(reqStream);
                sw.Write(post);
                sw.Flush();
                sw.Close();
            }

            if (filepath != null)
            {
                // download a file
                string tmpFilename = Path.Combine(DOWNLOAD_FOLDER, "_tmpdownload_" + filename);
                File.Delete(tmpFilename); // start again

                try
                {
                    var resp = (HttpWebResponse)req.GetResponse();

                    long contentLength = resp.ContentLength;
                    long totalBytesRead = 0;
                    long displayChunk = 0;

                    using (var respStream = resp.GetResponseStream())
                    {
                        using (var file = File.Create(tmpFilename))
                        {
                            byte[] buffer = new byte[65536];

                            for (; ; )
                            {
                                int bytesRead = respStream.Read(buffer, 0, buffer.Length);
                                if (bytesRead <= 0)
                                    break;

                                totalBytesRead += bytesRead;
                                displayChunk += bytesRead;
                                if (displayChunk >= 1048576)
                                {
                                    Log("downloaded {0} KB...", totalBytesRead / 1024);
                                    displayChunk = 0;
                                }

                                file.Write(buffer, 0, bytesRead);
                            }
                        }
                    }

                    if (contentLength != -1 && contentLength != totalBytesRead)
                        throw new Exception("Bad download - expected " + contentLength + " bytes, actually got " + totalBytesRead);

                    if (File.Exists(filepath))
                        File.Delete(filepath);

                    // only write etag after downloading
                    etag = resp.Headers.Get("ETag");
                    if (!String.IsNullOrWhiteSpace(etag))
                    {
                        File.WriteAllText(etagFilename, etag);
                    }


                    File.Move(tmpFilename, filepath);

                    return filepath;

                }
                catch (WebException ex)
                {
                    var exResp = ex.Response as HttpWebResponse;

                    if (exResp != null && exResp.StatusCode == HttpStatusCode.NotModified)
                    {
                        Log("already downloaded; no change");
                        return filepath;
                    }

                    // some other exception, log and ignore
                    Log("could not download; continuing with existing - " + ex.Message);
                    if (File.Exists(filepath))
                        return filepath;
                    else
                        throw ex;
                    
                }
            }
            else
            {
                // pull data (no download)
                var resp = req.GetResponse();

                StreamReader sr = new StreamReader(resp.GetResponseStream());
                String data = sr.ReadToEnd();

                return data;
            }
        }

        static String FindFileOrFail(String filename, String root)
        {
            var result = System.IO.Directory.GetFiles(root, "java.exe", SearchOption.AllDirectories);
            if (result == null || result.Length == 0)
                throw new Exception("Could not find file " + filename + " within " + root);

            return result[0];
        }

        static string GetProcessorName()
        {
            ManagementObjectSearcher searcher = new ManagementObjectSearcher("SELECT * FROM Win32_Processor");
            string procName = null;

            foreach (ManagementObject moProcessor in searcher.Get())
            {
                if (moProcessor["name"] != null)
                    procName = moProcessor["name"].ToString();
            }

            if (string.IsNullOrWhiteSpace(procName))
                throw new Exception("Could not determine processor name");

            return procName.Trim();
        }

        static string GetManufacturer()
        {
            ManagementObjectSearcher searcher = new ManagementObjectSearcher("SELECT * FROM Win32_ComputerSystem");
            string manuName = null;

            foreach (ManagementObject moProcessor in searcher.Get())
            {
                if (moProcessor["Manufacturer"] != null)
                    manuName = moProcessor["Manufacturer"].ToString();
            }

            if (string.IsNullOrWhiteSpace(manuName))
                throw new Exception("Could not determine manufacturer name");

            return manuName.Trim();
        }

        #endregion

        #region Windows/DLL

        [ComImport]
        [Guid("00021401-0000-0000-C000-000000000046")]
        internal class ShellLink
        {
        }

        [ComImport]
        [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
        [Guid("000214F9-0000-0000-C000-000000000046")]
        internal interface IShellLink
        {
            void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, out IntPtr pfd, int fFlags);
            void GetIDList(out IntPtr ppidl);
            void SetIDList(IntPtr pidl);
            void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
            void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
            void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
            void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
            void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
            void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
            void GetHotkey(out short pwHotkey);
            void SetHotkey(short wHotkey);
            void GetShowCmd(out int piShowCmd);
            void SetShowCmd(int iShowCmd);
            void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
            void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
            void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
            void Resolve(IntPtr hwnd, int fFlags);
            void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
        }

        #endregion

        #region Logging

        static void Log(string msg)
        {
            Console.WriteLine("nodel-init: " + msg);
        }

        static void Log(string msgFormat, params object[] args)
        {
            Log(string.Format(msgFormat, args));
        }

        #endregion

    }
}
Clone this wiki locally