From 064e43750f8433367c6e2f263c3d881705a43fe1 Mon Sep 17 00:00:00 2001
From: chylex <contact@chylex.com>
Date: Tue, 31 Jul 2018 12:48:07 +0200
Subject: [PATCH] Rewrite PostBuild into F# with optional compilation for
 performance

---
 README.md                                    |   6 +-
 Resources/PostBuild.fsx                      | 249 +++++++++++++++++++
 Resources/PostBuild.ps1                      | 168 -------------
 Resources/ScriptLoader.cs                    |   6 +-
 TweetDuck.csproj                             |  11 +-
 bld/{RUN BUILD.bat => GEN EVERYTHING.bat}    |   0
 bld/{UPDATE ONLY.bat => GEN UPDATE ONLY.bat} |   0
 bld/POST BUILD.bat                           |  16 ++
 8 files changed, 280 insertions(+), 176 deletions(-)
 create mode 100644 Resources/PostBuild.fsx
 delete mode 100644 Resources/PostBuild.ps1
 rename bld/{RUN BUILD.bat => GEN EVERYTHING.bat} (100%)
 rename bld/{UPDATE ONLY.bat => GEN UPDATE ONLY.bat} (100%)
 create mode 100644 bld/POST BUILD.bat

diff --git a/README.md b/README.md
index b8a393ad..fb2be802 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ TweetDuck uses **Inno Setup** for installers and updates. First, download and in
 
 Next, add the Inno Setup installation folder (usually `C:\Program Files (x86)\Inno Setup 5`) into your **PATH** environment variable. You may need to restart File Explorer for the change to take place.
 
-Now you can generate installers after a build by running `bld/RUN BUILD.bat`. Note that this will only package the files, you still need to run the [release build](#release) in Visual Studio!
+Now you can generate installers after a build by running `bld/GEN EVERYTHING.bat`. Note that this will only package the files, you still need to run the [release build](#release) in Visual Studio!
 
 After the window closes, three installers will be generated inside the `bld/Output` folder:
 * **TweetDuck.exe**
@@ -71,6 +71,6 @@ After the window closes, three installers will be generated inside the `bld/Outp
 
 > When opening **Batch Build**, you will also see `x64` and `AnyCPU` configurations. These are visible due to what I consider a Visual Studio bug, and will not work without significant changes to the project. Manually running the `Resources/PostCefUpdate.ps1` PowerShell script modifies the downloaded CefSharp packages, and removes the invalid configurations.
 
-> There is a small chance running `RUN BUILD.bat` immediately shows a resource error. If that happens, close the console window (which terminates all Inno Setup processes and leaves corrupted installers in the output folder), and run it again.
+> There is a small chance running `GEN EVERYTHING.bat` immediately shows a resource error. If that happens, close the console window (which terminates all Inno Setup processes and leaves corrupted installers in the output folder), and run it again.
 
-> Running `RUN BUILD.bat` uses about 400 MB of RAM due to high compression. You can lower this to about 140 MB by opening `gen_full.iss` and `gen_port.iss`, and changing `LZMADictionarySize=15360` to `LZMADictionarySize=4096`.
+> Running `GEN EVERYTHING.bat` uses about 400 MB of RAM due to high compression. You can lower this to about 140 MB by opening `gen_full.iss` and `gen_port.iss`, and changing `LZMADictionarySize=15360` to `LZMADictionarySize=4096`.
diff --git a/Resources/PostBuild.fsx b/Resources/PostBuild.fsx
new file mode 100644
index 00000000..f1d80987
--- /dev/null
+++ b/Resources/PostBuild.fsx
@@ -0,0 +1,249 @@
+open System
+open System.Collections.Generic
+open System.Diagnostics
+open System.IO
+open System.Text.RegularExpressions
+open System.Threading.Tasks
+
+// "$(DevEnvDir)CommonExtensions\Microsoft\FSharp\fsi.exe" "$(ProjectDir)Resources\PostBuild.fsx" --exec --nologo -- "$(TargetDir)\" "$(ProjectDir)\" "$(ConfigurationName)"
+// "$(ProjectDir)bld\post_build.exe" "$(TargetDir)\" "$(ProjectDir)\" "$(ConfigurationName)"
+
+exception ArgumentUsage of string
+
+#if !INTERACTIVE
+[<EntryPoint>]
+#endif
+let main (argv: string[]) =
+    try
+        if argv.Length < 2 then
+            #if INTERACTIVE
+            raise (ArgumentUsage "fsi.exe PostBuild.fsx --exec --nologo -- <TargetDir> <ProjectDir> [ConfigurationName] [VersionTag]")
+            #else
+            raise (ArgumentUsage "PostBuild.exe <TargetDir> <ProjectDir> [ConfigurationName] [VersionTag]")
+            #endif
+            
+        let _time name func =
+            let sw = Stopwatch.StartNew()
+            func()
+            sw.Stop()
+            printfn "[%s took %i ms]" name (int (Math.Round(sw.Elapsed.TotalMilliseconds)))
+            
+        let (+/) path1 path2 =
+            Path.Combine(path1, path2)
+            
+        let sw = Stopwatch.StartNew()
+        
+        // Setup
+        
+        let targetDir = argv.[0]
+        let projectDir = argv.[1]
+        
+        let configuration = if argv.Length >= 3 then argv.[2]
+                            else "Release"
+                            
+        let version = if argv.Length >= 4 then argv.[3]
+                      else ((targetDir +/ "TweetDuck.exe") |> FileVersionInfo.GetVersionInfo).FileVersion
+                      
+        printfn "--------------------------"
+        printfn "TweetDuck version %s" version
+        printfn "--------------------------"
+        
+        let localesDir = targetDir +/ "locales"
+        let scriptsDir = targetDir +/ "scripts"
+        let pluginsDir = targetDir +/ "plugins"
+        let importsDir = scriptsDir +/ "imports"
+        
+        // Functions (Strings)
+        
+        let filterNotEmpty =
+            Seq.filter (not << String.IsNullOrEmpty)
+            
+        let replaceRegex (pattern: string) (replacement: string) input =
+            Regex.Replace(input, pattern, replacement)
+            
+        let collapseLines separator (sequence: string seq) =
+            String.Join(separator, sequence)
+            
+        let trimStart (line: string) =
+            line.TrimStart()
+            
+        // Functions (File Management)
+        let copyFile source target =
+            File.Copy(source, target, true)
+            
+        let createDirectory path =
+            Directory.CreateDirectory(path) |> ignore
+            
+        let rec copyDirectoryContentsFiltered source target (filter: string -> bool) =
+            if not (Directory.Exists(target)) then
+                Directory.CreateDirectory(target) |> ignore
+                
+            let src = DirectoryInfo(source)
+            
+            for file in src.EnumerateFiles() do
+                if filter file.Name then
+                    file.CopyTo(target +/ file.Name) |> ignore
+                    
+            for dir in src.EnumerateDirectories() do
+                if filter dir.Name then
+                    copyDirectoryContentsFiltered dir.FullName (target +/ dir.Name) filter
+                    
+        let copyDirectoryContents source target =
+            copyDirectoryContentsFiltered source target (fun _ -> true)
+            
+        // Functions (File Processing)
+        
+        let byPattern path pattern =
+            Directory.EnumerateFiles(path, pattern, SearchOption.AllDirectories)
+            
+        let exceptEndingWith name =
+            Seq.filter (fun (file: string) -> not (file.EndsWith(name)))
+            
+        let iterateFiles (files: string seq) (func: string -> unit) =
+            Parallel.ForEach(files, func) |> ignore
+            
+        let readFile file = seq {
+            use stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 0x1000, FileOptions.SequentialScan)
+            use reader = new StreamReader(stream)
+            let mutable cont = true
+            
+            while cont do
+                let line = reader.ReadLine()
+                
+                if line = null then
+                    cont <- false
+                else
+                    yield line
+        }
+        
+        let writeFile (fullPath: string) (lines: string seq) =
+            let relativePath = fullPath.[(targetDir.Length)..]
+            let includeVersion = relativePath.StartsWith(@"scripts\") && not (relativePath.StartsWith(@"scripts\imports\"))
+            let finalLines = if includeVersion then seq { yield "#" + version; yield! lines } else lines
+            
+            File.WriteAllLines(fullPath, finalLines |> filterNotEmpty |> Seq.toArray)
+            printfn "Processed %s" relativePath
+        
+        let processFiles (files: string seq) (extProcessors: IDictionary<string, (string seq -> string seq)>) =
+            let rec processFileContents file =
+                readFile file
+                |> extProcessors.[Path.GetExtension(file)]
+                |> Seq.map (fun line ->
+                    Regex.Replace(line, @"#import ""(.*?)""", (fun matchInfo ->
+                        processFileContents(importsDir +/ matchInfo.Groups.[1].Value.Trim())
+                        |> collapseLines (Environment.NewLine)
+                        |> (fun contents -> contents.TrimEnd())
+                    ))
+                )
+                
+            iterateFiles files (fun file ->
+                processFileContents file
+                |> (writeFile file)
+            )
+            
+        // Build
+            
+        copyFile (projectDir +/ "bld/Resources/LICENSES.txt") (targetDir +/ "LICENSES.txt")
+        
+        copyDirectoryContents (projectDir +/ "Resources/Scripts") scriptsDir
+        
+        createDirectory (pluginsDir +/ "official")
+        createDirectory (pluginsDir +/ "user")
+        
+        copyDirectoryContentsFiltered
+            (projectDir +/ "Resources/Plugins")
+            (pluginsDir +/ "official")
+            (fun name -> name <> ".debug" && name <> "emoji-instructions.txt")
+            
+        if configuration = "Debug" then
+            copyDirectoryContents
+                (projectDir +/ "Resources/Plugins/.debug")
+                (pluginsDir +/ "user/.debug")
+                
+        if Directory.Exists(localesDir) || configuration = "Release" then
+            Directory.EnumerateFiles(localesDir, "*.pak")
+            |> exceptEndingWith @"\en-US.pak"
+            |> Seq.iter (File.Delete)
+            
+        // Validation
+        
+        if File.ReadAllText(pluginsDir +/ "official/emoji-keyboard/emoji-ordering.txt").IndexOf('\r') <> -1 then
+            raise (FormatException("emoji-ordering.txt must not have any carriage return characters"))
+        else
+            printfn "Verified emoji-ordering.txt"
+            
+        // Processing
+        
+        let fileProcessors =
+            dict [
+                ".js", (fun (lines: string seq) ->
+                    lines
+                    |> Seq.map (fun line ->
+                        line
+                        |> trimStart
+                        |> replaceRegex @"^(.*?)((?<=^|[;{}()])\s?//(?:\s.*|$))?$" "$1"
+                        |> replaceRegex @"(?<!\w)(return|throw)(\s.*?)? if (.*?);" "if ($3)$1$2;"
+                    )
+                );
+                
+                ".css", (fun (lines: string seq) ->
+                    lines
+                    |> Seq.map (fun line ->
+                        line
+                        |> replaceRegex @"\s*/\*.*?\*/" ""
+                        |> replaceRegex @"^(\S.*) {$" "$1{"
+                        |> replaceRegex @"^\s+(.+?):\s*(.+?)(?:\s*(!important))?;$" "$1:$2$3;"
+                    )
+                    |> filterNotEmpty
+                    |> collapseLines " "
+                    |> replaceRegex @"([{};])\s" "$1"
+                    |> replaceRegex @";}" "}"
+                    |> Seq.singleton
+                );
+                
+                ".html", (fun (lines: string seq) ->
+                    lines
+                    |> Seq.map trimStart
+                );
+                
+                ".meta", (fun (lines: string seq) ->
+                    lines
+                    |> Seq.map (fun line -> line.Replace("{version}", version))
+                );
+            ]
+            
+        processFiles ((byPattern targetDir "*.js") |> exceptEndingWith @"\configuration.default.js") fileProcessors
+        processFiles (byPattern targetDir "*.css") fileProcessors
+        processFiles (byPattern targetDir "*.html") fileProcessors
+        processFiles (byPattern pluginsDir "*.meta") fileProcessors
+        
+        // Cleanup
+        
+        Directory.Delete(importsDir, true)
+        
+        // Finished
+        
+        sw.Stop()
+        printfn "------------------"
+        printfn "Finished in %i ms" (int (Math.Round(sw.Elapsed.TotalMilliseconds)))
+        printfn "------------------"
+        0
+        
+    with
+    | ArgumentUsage message ->
+        printfn ""
+        printfn "Build script usage:"
+        printfn "%s" message
+        printfn ""
+        1
+    | ex ->
+        printfn ""
+        printfn "Encountered an error while running PostBuild:"
+        printfn "%A" ex
+        printfn ""
+        1
+        
+#if INTERACTIVE
+printfn "Running PostBuild in interpreter..."
+main (fsi.CommandLineArgs |> Array.skip (1 + (fsi.CommandLineArgs |> Array.findIndex (fun arg -> arg = "--"))))
+#endif
diff --git a/Resources/PostBuild.ps1 b/Resources/PostBuild.ps1
deleted file mode 100644
index ca109295..00000000
--- a/Resources/PostBuild.ps1
+++ /dev/null
@@ -1,168 +0,0 @@
-Param(
-  [Parameter(Mandatory = $True, Position = 1)][string] $targetDir,
-  [Parameter(Mandatory = $True, Position = 2)][string] $projectDir,
-  [Parameter(Position = 3)][string] $configuration = "Release",
-  [Parameter(Position = 4)][string] $version = ""
-)
-
-$ErrorActionPreference = "Stop"
-
-try{
-  $sw = [Diagnostics.Stopwatch]::StartNew()
-  
-  if ($version.Equals("")){
-    $version = (Get-Item (Join-Path $targetDir "TweetDuck.exe")).VersionInfo.FileVersion
-  }
-  
-  Write-Host "--------------------------"
-  Write-Host "TweetDuck version" $version
-  Write-Host "--------------------------"
-  
-  # Cleanup
-  
-  if (Test-Path (Join-Path $targetDir "locales")){
-    Remove-Item -Path (Join-Path $targetDir "locales\*.pak") -Exclude "en-US.pak"
-  }
-  
-  # Copy resources
-  
-  Copy-Item (Join-Path $projectDir "bld\Resources\LICENSES.txt") -Destination $targetDir -Force
-  
-  New-Item -ItemType directory -Path $targetDir -Name "scripts" | Out-Null
-  New-Item -ItemType directory -Path $targetDir -Name "plugins" | Out-Null
-  New-Item -ItemType directory -Path $targetDir -Name "plugins\official" | Out-Null
-  New-Item -ItemType directory -Path $targetDir -Name "plugins\user" | Out-Null
-  
-  Copy-Item (Join-Path $projectDir "Resources\Scripts\*") -Recurse -Destination (Join-Path $targetDir "scripts")
-  Copy-Item (Join-Path $projectDir "Resources\Plugins\*") -Recurse -Destination (Join-Path $targetDir "plugins\official") -Exclude ".debug"
-  
-  Remove-Item (Join-Path $targetDir "plugins\official\emoji-keyboard\emoji-instructions.txt")
-  
-  if ($configuration -eq "Debug"){
-    New-Item -ItemType directory -Path $targetDir -Name "plugins\user\.debug" | Out-Null
-    Copy-Item (Join-Path $projectDir "Resources\Plugins\.debug\*") -Recurse -Destination (Join-Path $targetDir "plugins\user\.debug")
-  }
-  
-  # Helper functions
-  
-  function Remove-Empty-Lines{
-    Param([Parameter(Mandatory = $True, Position = 1)] $lines)
-    
-    foreach($line in $lines){
-      if ($line -ne ''){
-        $line
-      }
-    }
-  }
-  
-  function Check-Carriage-Return{
-    Param([Parameter(Mandatory = $True, Position = 1)] $file)
-    
-    if (!(Test-Path $file)){
-      Throw "$file does not exist"
-    }
-    
-    if ((Get-Content -Path $file -Raw).Contains("`r")){
-      Throw "$file must not have any carriage return characters"
-    }
-    
-    Write-Host "Verified" $file.Substring($targetDir.Length)
-  }
-  
-  function Rewrite-File{
-    Param([Parameter(Mandatory = $True, Position = 1)] $file,
-          [Parameter(Mandatory = $True, Position = 2)] $lines,
-          [Parameter(Mandatory = $True, Position = 3)] $imports)
-    
-    $lines = Remove-Empty-Lines($lines)
-    $relativePath = $file.FullName.Substring($targetDir.Length)
-    
-    foreach($line in $lines){
-      if ($line.Contains('#import ')){
-        $imports.Add($file.FullName)
-        break
-      }
-    }
-    
-    if ($relativePath.StartsWith("scripts\")){
-      $lines = (,("#" + $version) + $lines)
-    }
-    
-    [IO.File]::WriteAllLines($file.FullName, $lines)
-    Write-Host "Processed" $relativePath
-  }
-  
-  # Validation
-  
-  Check-Carriage-Return(Join-Path $targetDir "plugins\official\emoji-keyboard\emoji-ordering.txt")
-  
-  # Processing
-  
-  $imports = New-Object "System.Collections.Generic.List[string]"
-  
-  foreach($file in Get-ChildItem -Path $targetDir -Filter "*.js" -Exclude "configuration.default.js" -Recurse){
-    $lines = [IO.File]::ReadLines($file.FullName)
-    $lines = $lines | ForEach-Object { $_.TrimStart() }
-    $lines = $lines -Replace '^(.*?)((?<=^|[;{}()])\s?//(?:\s.*|$))?$', '$1'
-    $lines = $lines -Replace '(?<!\w)(return|throw)(\s.*?)? if (.*?);', 'if ($3)$1$2;'
-    Rewrite-File $file $lines $imports
-  }
-  
-  foreach($file in Get-ChildItem -Path $targetDir -Filter "*.css" -Recurse){
-    $lines = [IO.File]::ReadLines($file.FullName)
-    $lines = $lines -Replace '\s*/\*.*?\*/', ''
-    $lines = $lines -Replace '^(\S.*) {$', '$1{'
-    $lines = $lines -Replace '^\s+(.+?):\s*(.+?)(?:\s*(!important))?;$', '$1:$2$3;'
-    $lines = @((Remove-Empty-Lines($lines)) -Join ' ')
-    $lines = $lines -Replace '([{};])\s', '$1'
-    $lines = $lines -Replace ';}', '}'
-    Rewrite-File $file $lines $imports
-  }
-  
-  foreach($file in Get-ChildItem -Path $targetDir -Filter "*.html" -Recurse){
-    $lines = [IO.File]::ReadLines($file.FullName)
-    $lines = $lines | ForEach-Object { $_.TrimStart() }
-    Rewrite-File $file $lines $imports
-  }
-  
-  foreach($file in Get-ChildItem -Path (Join-Path $targetDir "plugins") -Filter "*.meta" -Recurse){
-    $lines = [IO.File]::ReadLines($file.FullName)
-    $lines = $lines -Replace '\{version\}', $version
-    Rewrite-File $file $lines $imports
-  }
-  
-  # Imports
-  
-  $importFolder = Join-Path $targetDir "scripts\imports"
-  
-  foreach($path in $imports){
-    $text = [IO.File]::ReadAllText($path)
-    $text = [Regex]::Replace($text, '#import "(.*?)"', {
-      $importPath = Join-Path $importFolder ($args[0].Groups[1].Value.Trim())
-      $importStr = [IO.File]::ReadAllText($importPath).TrimEnd()
-      
-      if ($importStr[0] -eq '#'){
-        $importStr = $importStr.Substring($importStr.IndexOf("`n") + 1)
-      }
-      
-      return $importStr
-    }, [System.Text.RegularExpressions.RegexOptions]::MultiLine)
-    
-    [IO.File]::WriteAllText($path, $text)
-    Write-Host "Resolved" $path.Substring($targetDir.Length)
-  }
-  
-  [IO.Directory]::Delete($importFolder, $True)
-  
-  # Finished
-  
-  $sw.Stop()
-  Write-Host "------------------"
-  Write-Host "Finished in" $([math]::Round($sw.Elapsed.TotalMilliseconds)) "ms"
-  Write-Host "------------------"
-  
-}catch{
-  Write-Host "Encountered an error while running PostBuild.ps1 on line" $_.InvocationInfo.ScriptLineNumber
-  Write-Host $_
-  Exit 1
-}
diff --git a/Resources/ScriptLoader.cs b/Resources/ScriptLoader.cs
index f370c5ad..42a41959 100644
--- a/Resources/ScriptLoader.cs
+++ b/Resources/ScriptLoader.cs
@@ -87,7 +87,7 @@ private static void ShowLoadError(Control sync, string message){
         #if DEBUG
         private static readonly string HotSwapProjectRoot = FixPathSlash(Path.GetFullPath(Path.Combine(Program.ProgramPath, "../../../")));
         private static readonly string HotSwapTargetDir = FixPathSlash(Path.Combine(HotSwapProjectRoot, "bin", "tmp"));
-        private static readonly string HotSwapRebuildScript = Path.Combine(HotSwapProjectRoot, "Resources", "PostBuild.ps1");
+        private static readonly string HotSwapRebuildScript = Path.Combine(HotSwapProjectRoot, "bld", "post_build.exe");
 
         static ScriptLoader(){
             if (File.Exists(HotSwapRebuildScript)){
@@ -110,8 +110,8 @@ public static void HotSwap(){
             Stopwatch sw = Stopwatch.StartNew();
 
             using(Process process = Process.Start(new ProcessStartInfo{
-                FileName = "powershell",
-                Arguments = $"-ExecutionPolicy Unrestricted -File \"{HotSwapRebuildScript}\" \"{HotSwapTargetDir}\\\" \"{HotSwapProjectRoot}\\\" \"Debug\" \"{Program.VersionTag}\"",
+                FileName = HotSwapRebuildScript,
+                Arguments = $"\"{HotSwapTargetDir}\\\" \"{HotSwapProjectRoot}\\\" \"Debug\" \"{Program.VersionTag}\"",
                 WindowStyle = ProcessWindowStyle.Hidden
             })){
                 // ReSharper disable once PossibleNullReferenceException
diff --git a/TweetDuck.csproj b/TweetDuck.csproj
index dc7699dd..6fcce94f 100644
--- a/TweetDuck.csproj
+++ b/TweetDuck.csproj
@@ -394,15 +394,22 @@ rmdir "$(ProjectDir)bin\Release"
 rmdir "$(TargetDir)scripts" /S /Q
 rmdir "$(TargetDir)plugins" /S /Q
 
-powershell -ExecutionPolicy Unrestricted -File "$(ProjectDir)Resources\PostBuild.ps1" "$(TargetDir)\" "$(ProjectDir)\" "$(ConfigurationName)"
+IF EXIST "$(ProjectDir)bld\post_build.exe" (
+  "$(ProjectDir)bld\post_build.exe" "$(TargetDir)\" "$(ProjectDir)\" "$(ConfigurationName)"
+) ELSE (
+  "$(DevEnvDir)CommonExtensions\Microsoft\FSharp\fsi.exe" "$(ProjectDir)Resources\PostBuild.fsx" --exec --nologo -- "$(TargetDir)\" "$(ProjectDir)\" "$(ConfigurationName)"
+)
 </PostBuildEvent>
   </PropertyGroup>
+  <Target Name="BeforeBuild" Condition="(!$([System.IO.File]::Exists(&quot;$(ProjectDir)\bld\post_build.exe&quot;)) OR ($([System.IO.File]::GetLastWriteTime(&quot;$(ProjectDir)\Resources\PostBuild.fsx&quot;).Ticks) &gt; $([System.IO.File]::GetLastWriteTime(&quot;$(ProjectDir)\bld\post_build.exe&quot;).Ticks)))">
+    <Exec Command="&quot;$(ProjectDir)bld\POST BUILD.bat&quot;" WorkingDirectory="$(ProjectDir)bld\" IgnoreExitCode="true" />
+  </Target>
   <Target Name="AfterBuild" Condition="$(ConfigurationName) == Release">
     <Exec Command="del &quot;$(TargetDir)*.pdb&quot;" />
     <Exec Command="del &quot;$(TargetDir)*.xml&quot;" />
     <Delete Files="$(TargetDir)CefSharp.BrowserSubprocess.exe" />
     <Delete Files="$(TargetDir)widevinecdmadapter.dll" />
-    <Exec Command="&quot;$(ProjectDir)bld\UPDATE ONLY.bat&quot;" WorkingDirectory="$(ProjectDir)bld\" IgnoreExitCode="true" />
+    <Exec Command="&quot;$(ProjectDir)bld\GEN UPDATE ONLY.bat&quot;" WorkingDirectory="$(ProjectDir)bld\" IgnoreExitCode="true" />
   </Target>
   <PropertyGroup>
     <PreBuildEvent>powershell Get-Process TweetDuck.Browser -ErrorAction SilentlyContinue ^| Where-Object {$_.Path -eq '$(TargetDir)TweetDuck.Browser.exe'} ^| Stop-Process; Exit 0</PreBuildEvent>
diff --git a/bld/RUN BUILD.bat b/bld/GEN EVERYTHING.bat
similarity index 100%
rename from bld/RUN BUILD.bat
rename to bld/GEN EVERYTHING.bat
diff --git a/bld/UPDATE ONLY.bat b/bld/GEN UPDATE ONLY.bat
similarity index 100%
rename from bld/UPDATE ONLY.bat
rename to bld/GEN UPDATE ONLY.bat
diff --git a/bld/POST BUILD.bat b/bld/POST BUILD.bat
new file mode 100644
index 00000000..ef6c7c4b
--- /dev/null
+++ b/bld/POST BUILD.bat	
@@ -0,0 +1,16 @@
+@ECHO OFF
+
+DEL "post_build.exe"
+
+SET fsc="%PROGRAMFILES(x86)%\Microsoft SDKs\F#\10.1\Framework\v4.0\fsc.exe"
+
+IF NOT EXIST %fsc% (
+  SET fsc="%PROGRAMFILES%\Microsoft SDKs\F#\10.1\Framework\v4.0\fsc.exe"
+)
+
+IF NOT EXIST %fsc% (
+  ECHO fsc.exe not found
+  EXIT 1
+)
+
+%fsc% --standalone --deterministic --preferreduilang:en-US --platform:x86 --target:exe --out:post_build.exe "%~dp0..\Resources\PostBuild.fsx"