Skip to content

Commit ed4c1f8

Browse files
committed
Add Steam Workshop upload target
1 parent b0216fc commit ed4c1f8

3 files changed

Lines changed: 302 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
using Microsoft.VisualStudio.TestTools.UnitTesting;
2+
using System.Diagnostics;
3+
using System.Runtime.InteropServices;
4+
5+
namespace BetterModMenu.Tests;
6+
7+
[TestClass]
8+
public class ProjectFileTests
9+
{
10+
[TestMethod]
11+
public void RunSteamWorkshopUpload_StagesContentAndRunsUploaderFromUploaderRoot()
12+
{
13+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
14+
{
15+
Assert.Inconclusive("This test uses a shell-script fake ModUploader.exe.");
16+
}
17+
18+
using TempDirectory temp = new();
19+
string uploaderRoot = Path.Combine(temp.Path, "ModUploader-win-x64");
20+
string workshopDir = Path.Combine(uploaderRoot, "workshop", "BetterModMenu");
21+
string fakeDll = Path.Combine(temp.Path, "build", "BetterModMenu.dll");
22+
string capturePath = Path.Combine(temp.Path, "capture.txt");
23+
24+
Directory.CreateDirectory(uploaderRoot);
25+
Directory.CreateDirectory(workshopDir);
26+
Directory.CreateDirectory(Path.GetDirectoryName(fakeDll)!);
27+
File.WriteAllText(Path.Combine(workshopDir, "workshop.json"), "{}");
28+
File.WriteAllText(fakeDll, "fake dll");
29+
WriteFakeUploader(Path.Combine(uploaderRoot, "ModUploader.exe"));
30+
31+
DotnetResult result = RunDotnet(
32+
"msbuild",
33+
ProjectPath,
34+
"-t:RunSteamWorkshopUpload",
35+
$"-p:ModUploaderRoot={uploaderRoot}",
36+
$"-p:ModUploaderWorkshopDir={workshopDir}",
37+
$"-p:TargetPath={fakeDll}",
38+
"-p:TargetFileName=BetterModMenu.dll",
39+
capturePath);
40+
41+
Assert.AreEqual(0, result.ExitCode, result.Output);
42+
Assert.IsTrue(File.Exists(Path.Combine(workshopDir, "content", "BetterModMenu.dll")));
43+
Assert.IsTrue(File.Exists(Path.Combine(workshopDir, "content", "BetterModMenu.json")));
44+
45+
string[] captured = File.ReadAllLines(capturePath);
46+
Assert.AreEqual(Path.GetFullPath(uploaderRoot), Path.GetFullPath(captured[0]));
47+
CollectionAssert.AreEqual(
48+
new[] { "upload", "-w", GetUploadPath(workshopDir) },
49+
captured.Skip(1).ToArray());
50+
}
51+
52+
[TestMethod]
53+
public void RunSteamWorkshopUpload_FailsBeforeUploaderWhenUploaderIsMissing()
54+
{
55+
using TempDirectory temp = new();
56+
string uploaderRoot = Path.Combine(temp.Path, "ModUploader-win-x64");
57+
string workshopDir = Path.Combine(uploaderRoot, "workshop", "BetterModMenu");
58+
string fakeDll = Path.Combine(temp.Path, "build", "BetterModMenu.dll");
59+
string capturePath = Path.Combine(temp.Path, "capture.txt");
60+
61+
Directory.CreateDirectory(workshopDir);
62+
Directory.CreateDirectory(Path.GetDirectoryName(fakeDll)!);
63+
File.WriteAllText(Path.Combine(workshopDir, "workshop.json"), "{}");
64+
File.WriteAllText(fakeDll, "fake dll");
65+
66+
DotnetResult result = RunDotnet(
67+
"msbuild",
68+
ProjectPath,
69+
"-t:RunSteamWorkshopUpload",
70+
$"-p:ModUploaderRoot={uploaderRoot}",
71+
$"-p:ModUploaderWorkshopDir={workshopDir}",
72+
$"-p:TargetPath={fakeDll}",
73+
"-p:TargetFileName=BetterModMenu.dll",
74+
capturePath);
75+
76+
Assert.AreNotEqual(0, result.ExitCode, result.Output);
77+
StringAssert.Contains(result.Output, "ModUploader.exe not found");
78+
Assert.IsFalse(File.Exists(capturePath));
79+
}
80+
81+
[TestMethod]
82+
public void RunSteamWorkshopUpload_FailsBeforeUploaderWhenWorkshopIsMissing()
83+
{
84+
using TempDirectory temp = new();
85+
string uploaderRoot = Path.Combine(temp.Path, "ModUploader-win-x64");
86+
string workshopDir = Path.Combine(uploaderRoot, "workshop", "BetterModMenu");
87+
string fakeDll = Path.Combine(temp.Path, "build", "BetterModMenu.dll");
88+
string capturePath = Path.Combine(temp.Path, "capture.txt");
89+
90+
Directory.CreateDirectory(workshopDir);
91+
Directory.CreateDirectory(Path.GetDirectoryName(fakeDll)!);
92+
File.WriteAllText(fakeDll, "fake dll");
93+
WriteFakeUploader(Path.Combine(uploaderRoot, "ModUploader.exe"));
94+
95+
DotnetResult result = RunDotnet(
96+
"msbuild",
97+
ProjectPath,
98+
"-t:RunSteamWorkshopUpload",
99+
$"-p:ModUploaderRoot={uploaderRoot}",
100+
$"-p:ModUploaderWorkshopDir={workshopDir}",
101+
$"-p:TargetPath={fakeDll}",
102+
"-p:TargetFileName=BetterModMenu.dll",
103+
capturePath);
104+
105+
Assert.AreNotEqual(0, result.ExitCode, result.Output);
106+
StringAssert.Contains(result.Output, "Steam Workshop workspace not found");
107+
Assert.IsFalse(File.Exists(capturePath));
108+
}
109+
110+
[TestMethod]
111+
public void RunSteamWorkshopUpload_FailsBeforeUploaderWhenContentHasNestedUnexpectedFiles()
112+
{
113+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
114+
{
115+
Assert.Inconclusive("This test uses a shell-script fake ModUploader.exe.");
116+
}
117+
118+
using TempDirectory temp = new();
119+
string uploaderRoot = Path.Combine(temp.Path, "ModUploader-win-x64");
120+
string workshopDir = Path.Combine(uploaderRoot, "workshop", "BetterModMenu");
121+
string staleDir = Path.Combine(workshopDir, "content", "old");
122+
string fakeDll = Path.Combine(temp.Path, "build", "BetterModMenu.dll");
123+
string capturePath = Path.Combine(temp.Path, "capture.txt");
124+
125+
Directory.CreateDirectory(uploaderRoot);
126+
Directory.CreateDirectory(staleDir);
127+
Directory.CreateDirectory(Path.GetDirectoryName(fakeDll)!);
128+
File.WriteAllText(Path.Combine(workshopDir, "workshop.json"), "{}");
129+
File.WriteAllText(Path.Combine(staleDir, "stale.txt"), "stale");
130+
File.WriteAllText(fakeDll, "fake dll");
131+
WriteFakeUploader(Path.Combine(uploaderRoot, "ModUploader.exe"));
132+
133+
DotnetResult result = RunDotnet(
134+
"msbuild",
135+
ProjectPath,
136+
"-t:RunSteamWorkshopUpload",
137+
$"-p:ModUploaderRoot={uploaderRoot}",
138+
$"-p:ModUploaderWorkshopDir={workshopDir}",
139+
$"-p:TargetPath={fakeDll}",
140+
"-p:TargetFileName=BetterModMenu.dll",
141+
capturePath);
142+
143+
Assert.AreNotEqual(0, result.ExitCode, result.Output);
144+
StringAssert.Contains(result.Output, "ModUploader content folder contains extra files");
145+
Assert.IsFalse(File.Exists(capturePath));
146+
}
147+
148+
[TestMethod]
149+
public void UploadSteamWorkshop_IsExplicitAndBuildBacked()
150+
{
151+
string project = File.ReadAllText(ProjectPath);
152+
153+
StringAssert.Contains(project, "<Target Name=\"UploadSteamWorkshop\" DependsOnTargets=\"Build;RunSteamWorkshopUpload\" />");
154+
Assert.IsFalse(project.Contains("AfterTargets=\"Build\" Condition=\"'$(UploadSteamWorkshop)'", StringComparison.Ordinal));
155+
Assert.IsFalse(project.Contains("BeforeTargets=\"Build\" Condition=\"'$(UploadSteamWorkshop)'", StringComparison.Ordinal));
156+
}
157+
158+
private static string ProjectPath => Path.Combine(RepoRoot, "BetterModMenu.csproj");
159+
160+
private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
161+
162+
private static DotnetResult RunDotnet(
163+
string command,
164+
string projectPath,
165+
string target,
166+
string uploaderRoot,
167+
string workshopDir,
168+
string targetPath,
169+
string targetFileName,
170+
string capturePath)
171+
{
172+
ProcessStartInfo startInfo = new("dotnet")
173+
{
174+
RedirectStandardError = true,
175+
RedirectStandardOutput = true,
176+
UseShellExecute = false,
177+
WorkingDirectory = RepoRoot
178+
};
179+
180+
startInfo.ArgumentList.Add(command);
181+
startInfo.ArgumentList.Add(projectPath);
182+
startInfo.ArgumentList.Add(target);
183+
startInfo.ArgumentList.Add(uploaderRoot);
184+
startInfo.ArgumentList.Add(workshopDir);
185+
startInfo.ArgumentList.Add(targetPath);
186+
startInfo.ArgumentList.Add(targetFileName);
187+
startInfo.Environment["MOD_UPLOADER_CAPTURE"] = capturePath;
188+
189+
using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet.");
190+
string stdout = process.StandardOutput.ReadToEnd();
191+
string stderr = process.StandardError.ReadToEnd();
192+
process.WaitForExit();
193+
194+
return new DotnetResult(process.ExitCode, stdout + stderr);
195+
}
196+
197+
private static void WriteFakeUploader(string path)
198+
{
199+
File.WriteAllText(
200+
path,
201+
"""
202+
#!/usr/bin/env bash
203+
{
204+
pwd
205+
printf '%s\n' "$@"
206+
} > "$MOD_UPLOADER_CAPTURE"
207+
""");
208+
209+
if (!OperatingSystem.IsWindows())
210+
{
211+
File.SetUnixFileMode(
212+
path,
213+
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
214+
}
215+
}
216+
217+
private static string GetUploadPath(string path)
218+
{
219+
string fullPath = Path.GetFullPath(path);
220+
return fullPath.EndsWith(Path.DirectorySeparatorChar)
221+
? fullPath + "."
222+
: fullPath + Path.DirectorySeparatorChar + ".";
223+
}
224+
225+
private sealed record DotnetResult(int ExitCode, string Output);
226+
227+
private sealed class TempDirectory : IDisposable
228+
{
229+
public TempDirectory()
230+
{
231+
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"bmm-tests-{Guid.NewGuid():N}");
232+
Directory.CreateDirectory(Path);
233+
}
234+
235+
public string Path { get; }
236+
237+
public void Dispose()
238+
{
239+
try
240+
{
241+
Directory.Delete(Path, recursive: true);
242+
}
243+
catch (IOException)
244+
{
245+
}
246+
}
247+
}
248+
}

BetterModMenu.csproj

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<ArtifactsDir Condition="'$(ArtifactsDir)' == ''">$(ProjectDir)artifacts\</ArtifactsDir>
1818
<PrepareWorkshopWorkspaceOnBuild Condition="'$(PrepareWorkshopWorkspaceOnBuild)' == ''">false</PrepareWorkshopWorkspaceOnBuild>
1919
<Sts2DllPath Condition="'$(Sts2DllPath)' == '' and '$(STS2_DLL_PATH)' != ''">$(STS2_DLL_PATH)</Sts2DllPath>
20+
<ModUploaderRoot Condition="'$(ModUploaderRoot)' == '' and '$(MOD_UPLOADER_ROOT)' != ''">$(MOD_UPLOADER_ROOT)</ModUploaderRoot>
2021
<ModUploaderWorkshopDir Condition="'$(ModUploaderWorkshopDir)' == '' and '$(MOD_UPLOADER_WORKSHOP_DIR)' != ''">$(MOD_UPLOADER_WORKSHOP_DIR)</ModUploaderWorkshopDir>
2122
</PropertyGroup>
2223

@@ -106,4 +107,35 @@
106107

107108
<Message Text="Staged ModUploader workshop content: $(NormalizedModUploaderWorkshopDir)" Importance="High" />
108109
</Target>
110+
111+
<Target Name="ResolveSteamWorkshopUploadPaths">
112+
<PropertyGroup>
113+
<ResolvedModUploaderRoot Condition="'$(ModUploaderRoot)' != ''">$([MSBuild]::EnsureTrailingSlash('$(ModUploaderRoot)'))</ResolvedModUploaderRoot>
114+
<ResolvedModUploaderRoot Condition="'$(ResolvedModUploaderRoot)' == ''">$([MSBuild]::NormalizeDirectory('$(ProjectDir)', '..', 'ModUploader-win-x64'))</ResolvedModUploaderRoot>
115+
<ResolvedModUploaderExe>$(ResolvedModUploaderRoot)ModUploader.exe</ResolvedModUploaderExe>
116+
<ResolvedModUploaderWorkshopDir Condition="'$(ModUploaderWorkshopDir)' != ''">$([MSBuild]::EnsureTrailingSlash('$(ModUploaderWorkshopDir)'))</ResolvedModUploaderWorkshopDir>
117+
<ResolvedModUploaderWorkshopDir Condition="'$(ResolvedModUploaderWorkshopDir)' == ''">$([MSBuild]::NormalizeDirectory('$(ResolvedModUploaderRoot)', 'workshop', 'BetterModMenu'))</ResolvedModUploaderWorkshopDir>
118+
<ResolvedModUploaderWorkshopUploadDir>$(ResolvedModUploaderWorkshopDir).</ResolvedModUploaderWorkshopUploadDir>
119+
<ResolvedModUploaderWorkshopContentDir>$(ResolvedModUploaderWorkshopDir)content\</ResolvedModUploaderWorkshopContentDir>
120+
</PropertyGroup>
121+
</Target>
122+
123+
<Target Name="UploadSteamWorkshop" DependsOnTargets="Build;RunSteamWorkshopUpload" />
124+
125+
<Target Name="RunSteamWorkshopUpload" DependsOnTargets="ResolveSteamWorkshopUploadPaths">
126+
<ItemGroup>
127+
<ResolvedModUploaderWorkshopContentFiles Include="$(ResolvedModUploaderWorkshopContentDir)**\*" />
128+
<ResolvedUnexpectedModUploaderWorkshopContent Include="@(ResolvedModUploaderWorkshopContentFiles)" Exclude="$(ResolvedModUploaderWorkshopContentDir)$(TargetFileName);$(ResolvedModUploaderWorkshopContentDir)BetterModMenu.json" />
129+
</ItemGroup>
130+
131+
<Error Condition="!Exists('$(ResolvedModUploaderExe)')" Text="ModUploader.exe not found. Set ModUploaderRoot or MOD_UPLOADER_ROOT to the directory containing ModUploader.exe." />
132+
<Error Condition="!Exists('$(ResolvedModUploaderWorkshopDir)workshop.json')" Text="Steam Workshop workspace not found. Set ModUploaderWorkshopDir or MOD_UPLOADER_WORKSHOP_DIR to the workshop root containing workshop.json." />
133+
<Error Condition="'@(ResolvedUnexpectedModUploaderWorkshopContent)' != ''" Text="ModUploader content folder contains extra files. Remove them before uploading: @(ResolvedUnexpectedModUploaderWorkshopContent)" />
134+
<MakeDir Directories="$(ResolvedModUploaderWorkshopContentDir)" />
135+
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(ResolvedModUploaderWorkshopContentDir)" />
136+
<Copy SourceFiles="$(ProjectDir)BetterModMenu.json" DestinationFolder="$(ResolvedModUploaderWorkshopContentDir)" />
137+
138+
<Message Text="Uploading Steam Workshop item from $(ResolvedModUploaderWorkshopDir)" Importance="High" />
139+
<Exec WorkingDirectory="$(ResolvedModUploaderRoot)" Command="&quot;$(ResolvedModUploaderExe)&quot; upload -w &quot;$(ResolvedModUploaderWorkshopUploadDir)&quot;" />
140+
</Target>
109141
</Project>

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,28 @@ root that contains `workshop.json`, not the `content` folder itself. You can
9393
also set `MOD_UPLOADER_WORKSHOP_DIR` instead of passing the build property every
9494
time.
9595

96+
To build, refresh the ModUploader workspace content, and upload to Steam Workshop
97+
from this repo without changing into the ModUploader directory:
98+
99+
```powershell
100+
dotnet build BetterModMenu.csproj -t:UploadSteamWorkshop -p:Sts2DllPath="C:\path\to\sts2.dll"
101+
```
102+
103+
By default, `UploadSteamWorkshop` looks for the uploader beside this repo at
104+
`..\ModUploader-win-x64\ModUploader.exe` and uses
105+
`..\ModUploader-win-x64\workshop\BetterModMenu`. Override those paths with:
106+
107+
```powershell
108+
dotnet build BetterModMenu.csproj -t:UploadSteamWorkshop -p:Sts2DllPath="C:\path\to\sts2.dll" -p:ModUploaderRoot="E:\dev\STS2 mod\ModUploader-win-x64" -p:ModUploaderWorkshopDir="E:\dev\STS2 mod\ModUploader-win-x64\workshop\BetterModMenu"
109+
```
110+
111+
`UploadSteamWorkshop` is explicit-only; ordinary `dotnet build` commands do not
112+
upload to Steam. Use the Windows `dotnet` command from PowerShell or Windows
113+
Terminal for real uploads; WSL path handoff to `ModUploader.exe` is not verified.
114+
The upload target fails if the workspace `content` folder contains files other
115+
than `BetterModMenu.dll` and `BetterModMenu.json`, so stale files are not
116+
published by accident.
117+
96118
Cloud-capable builds are opt-in and produce a separate `_cloud` archive:
97119

98120
```powershell

0 commit comments

Comments
 (0)