com.robotsquid.squidhub 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.5
4
+
5
+ **Upload a build straight from Unity.** `SquidHub → Upload Build…` picks the
6
+ build folder, checks it, and pushes it to SquidHub — no console, no command
7
+ line, no repository checkout.
8
+
9
+ Set it up once in `SquidHub → Upload Settings` with a key from the publisher
10
+ console (your game → **Upload keys**).
11
+
12
+ ⚠️ **The key is stored in EditorPrefs, never in your project.** It is per-machine
13
+ and per-project-path, so there is no path by which it reaches your repository —
14
+ a better guarantee than being told not to commit it. A key can do exactly one
15
+ thing: upload a build for the one game it was minted for. It cannot read
16
+ anything, publish to a channel, or touch another game.
17
+
18
+ ⚠️ **Uploading is not publishing.** A build arrives registered and not live;
19
+ promoting it to players stays a deliberate act in the console.
20
+
21
+ The build is refused before anything uploads if it is precompressed, or has no
22
+ `index.html` at its top level. `.DS_Store` and friends are dropped rather than
23
+ uploaded — which is also what makes a Unity-pushed build hash identically to the
24
+ same bytes pushed by `squidhub-release` or dragged into the console.
25
+
3
26
  ## 0.5.4
4
27
 
5
28
  **No functional change from 0.5.3.**
@@ -0,0 +1,165 @@
1
+ // Turning a build folder into the file list the upload API expects.
2
+ //
3
+ // ⚠️ Pure System.* — no UnityEditor, no UnityEngine, no network. That is
4
+ // deliberate: this is the only part of the uploader with real logic, so it
5
+ // stays runnable under plain `dotnet` in tools/test. The same split is what
6
+ // made SquidHubHtml testable.
7
+ using System;
8
+ using System.Collections.Generic;
9
+ using System.IO;
10
+ using System.Linq;
11
+ using System.Security.Cryptography;
12
+
13
+ namespace SquidHub.Editor
14
+ {
15
+ internal sealed class ScannedFile
16
+ {
17
+ /// <summary>Forward-slashed path relative to the build root — the R2 key.</summary>
18
+ public string Path;
19
+ public string Sha256;
20
+ public long Bytes;
21
+ /// <summary>Absolute path, for the upload step.</summary>
22
+ public string FullPath;
23
+ }
24
+
25
+ internal sealed class BuildScan
26
+ {
27
+ public readonly List<ScannedFile> Files = new List<ScannedFile>();
28
+ /// <summary>Reasons this build cannot be uploaded. Non-empty means stop.</summary>
29
+ public readonly List<string> Refusals = new List<string>();
30
+ /// <summary>OS metadata dropped. Reported, never silent.</summary>
31
+ public readonly List<string> Skipped = new List<string>();
32
+ public long TotalBytes;
33
+ public bool HasEntryPoint;
34
+ }
35
+
36
+ internal static class SquidHubBuildScan
37
+ {
38
+ /// <summary>
39
+ /// ⚠️ Dropped, not refused — and it must match what the CLI and console do.
40
+ /// They hash a directory listing that never contains these, so filtering
41
+ /// here is what makes all three agree on a buildHash.
42
+ /// </summary>
43
+ private static bool IsJunk(string relativePath)
44
+ {
45
+ var name = relativePath.Split('/').Last();
46
+ return name == ".DS_Store" || name == "Thumbs.db" || relativePath.StartsWith("__MACOSX/");
47
+ }
48
+
49
+ /// <summary>
50
+ /// ⚠️ Precompressed builds cannot be served — Cloudflare either strips
51
+ /// Content-Encoding or compresses the body a second time. The server
52
+ /// refuses these too and is the authority; refusing here means finding
53
+ /// out before uploading a couple of hundred megabytes.
54
+ /// </summary>
55
+ private static string RefusalFor(string relativePath)
56
+ {
57
+ foreach (var pair in new[] { (".br", "Brotli"), (".gz", "gzip"), (".zz", "deflate") })
58
+ {
59
+ if (!relativePath.EndsWith(pair.Item1, StringComparison.OrdinalIgnoreCase)) continue;
60
+ return relativePath + " is " + pair.Item2 + "-compressed. SquidHub serves build " +
61
+ "assets uncompressed and Cloudflare compresses them at the edge. Set Player " +
62
+ "Settings > Publishing Settings > Compression Format to Disabled and rebuild.";
63
+ }
64
+ return null;
65
+ }
66
+
67
+ public static BuildScan Scan(string root)
68
+ {
69
+ var scan = new BuildScan();
70
+ var full = System.IO.Path.GetFullPath(root);
71
+
72
+ foreach (var file in Directory.GetFiles(full, "*", SearchOption.AllDirectories).OrderBy(p => p))
73
+ {
74
+ // ⚠️ Backslashes normalised. A path hashed as `Build\x.wasm` on
75
+ // Windows and `Build/x.wasm` elsewhere is the same bytes producing
76
+ // two different buildHashes.
77
+ var relative = file.Substring(full.Length).TrimStart('/', '\\').Replace('\\', '/');
78
+
79
+ if (IsJunk(relative))
80
+ {
81
+ scan.Skipped.Add(relative);
82
+ continue;
83
+ }
84
+
85
+ var refusal = RefusalFor(relative);
86
+ if (refusal != null)
87
+ {
88
+ scan.Refusals.Add(refusal);
89
+ continue;
90
+ }
91
+
92
+ var bytes = File.ReadAllBytes(file);
93
+ scan.Files.Add(new ScannedFile
94
+ {
95
+ Path = relative,
96
+ Sha256 = ToHex(SHA256.Create().ComputeHash(bytes)),
97
+ Bytes = bytes.LongLength,
98
+ FullPath = file,
99
+ });
100
+ scan.TotalBytes += bytes.LongLength;
101
+ }
102
+
103
+ // ⚠️ Checked before anything uploads. The portal frames
104
+ // /v/<hash>/index.html, so a build without one registers perfectly
105
+ // and then fails to launch for every player.
106
+ scan.HasEntryPoint = scan.Files.Any(f => f.Path == "index.html");
107
+ if (!scan.HasEntryPoint)
108
+ {
109
+ scan.Refusals.Add(
110
+ "No index.html at the top level of " + root + ". The game cannot launch. " +
111
+ "Point this at the folder Unity built into, not at its parent.");
112
+ }
113
+
114
+ return scan;
115
+ }
116
+
117
+ private static string ToHex(byte[] bytes)
118
+ {
119
+ var chars = new char[bytes.Length * 2];
120
+ for (var i = 0; i < bytes.Length; i++)
121
+ {
122
+ chars[i * 2] = "0123456789abcdef"[bytes[i] >> 4];
123
+ chars[i * 2 + 1] = "0123456789abcdef"[bytes[i] & 0xF];
124
+ }
125
+ return new string(chars);
126
+ }
127
+
128
+ /// <summary>
129
+ /// The `files` array for the start request.
130
+ ///
131
+ /// ⚠️ Hand-built rather than via a JSON library, for the same reason the
132
+ /// generated wrapper writes its own: a Unity project already carries one
133
+ /// and shipping a second is a second parser to maintain.
134
+ /// </summary>
135
+ public static string FilesJson(IEnumerable<ScannedFile> files)
136
+ {
137
+ var parts = files.Select(f =>
138
+ "{\"path\":" + JsonString(f.Path) +
139
+ ",\"sha256\":\"" + f.Sha256 + "\"" +
140
+ ",\"bytes\":" + f.Bytes + "}");
141
+ return "[" + string.Join(",", parts) + "]";
142
+ }
143
+
144
+ internal static string JsonString(string value)
145
+ {
146
+ var sb = new System.Text.StringBuilder("\"");
147
+ foreach (var c in value)
148
+ {
149
+ switch (c)
150
+ {
151
+ case '"': sb.Append("\\\""); break;
152
+ case '\\': sb.Append("\\\\"); break;
153
+ case '\n': sb.Append("\\n"); break;
154
+ case '\r': sb.Append("\\r"); break;
155
+ case '\t': sb.Append("\\t"); break;
156
+ default:
157
+ if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4"));
158
+ else sb.Append(c);
159
+ break;
160
+ }
161
+ }
162
+ return sb.Append('"').ToString();
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: 90a9c4481ddb3f880e468bdbe270b8a1
3
+ MonoImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ defaultReferences: []
7
+ executionOrder: 0
8
+ icon: {instanceID: 0}
9
+ userData:
10
+ assetBundleName:
11
+ assetBundleVariant:
@@ -0,0 +1,98 @@
1
+ // Where the upload key lives, and where it must not.
2
+ //
3
+ // ⚠️ EditorPrefs, NEVER a file in the project. A key written under Assets/ ends
4
+ // up in the publisher's git repository, and from there in anyone's clone. This
5
+ // is per-machine and per-user by construction, so there is no path by which
6
+ // committing it is possible — which is a better guarantee than telling people
7
+ // not to.
8
+ using UnityEditor;
9
+ using UnityEngine;
10
+
11
+ namespace SquidHub.Editor
12
+ {
13
+ internal static class SquidHubUploadSettings
14
+ {
15
+ // ⚠️ Keyed per project path, so two games open on one machine cannot
16
+ // upload to each other by inheriting a key.
17
+ private static string Scope => "SquidHub." + Application.dataPath.GetHashCode() + ".";
18
+
19
+ private const string DefaultEndpoint = "https://publish.robotsquid.dev";
20
+
21
+ public static string Key
22
+ {
23
+ get => EditorPrefs.GetString(Scope + "uploadKey", "");
24
+ set => EditorPrefs.SetString(Scope + "uploadKey", value.Trim());
25
+ }
26
+
27
+ public static string Endpoint
28
+ {
29
+ get => EditorPrefs.GetString(Scope + "endpoint", DefaultEndpoint).TrimEnd('/');
30
+ set => EditorPrefs.SetString(Scope + "endpoint", value.Trim().TrimEnd('/'));
31
+ }
32
+
33
+ public static string BuildFolder
34
+ {
35
+ get => EditorPrefs.GetString(Scope + "buildFolder", "");
36
+ set => EditorPrefs.SetString(Scope + "buildFolder", value);
37
+ }
38
+
39
+ public static bool HasKey => Key.StartsWith("shu_");
40
+ }
41
+
42
+ internal sealed class SquidHubUploadSettingsWindow : EditorWindow
43
+ {
44
+ private string _key = "";
45
+ private string _endpoint = "";
46
+
47
+ [MenuItem("SquidHub/Upload Settings", priority = 10)]
48
+ private static void Open()
49
+ {
50
+ var window = GetWindow<SquidHubUploadSettingsWindow>(true, "SquidHub Upload", true);
51
+ window._key = SquidHubUploadSettings.Key;
52
+ window._endpoint = SquidHubUploadSettings.Endpoint;
53
+ window.minSize = new Vector2(460, 210);
54
+ }
55
+
56
+ private void OnGUI()
57
+ {
58
+ EditorGUILayout.HelpBox(
59
+ "Create a key in the publisher console under your game → Upload keys. " +
60
+ "It can only upload builds for that one game.\n\n" +
61
+ "The key is stored in EditorPrefs on this machine, never in your project, " +
62
+ "so it cannot reach your repository.",
63
+ MessageType.Info);
64
+
65
+ // ⚠️ A password field. Not because it is secret from the person
66
+ // typing it, but because it is pasted during screen shares and
67
+ // recorded tutorials more often than anyone expects.
68
+ _key = EditorGUILayout.PasswordField("Upload key", _key);
69
+ _endpoint = EditorGUILayout.TextField("Console URL", _endpoint);
70
+
71
+ EditorGUILayout.Space();
72
+ using (new EditorGUILayout.HorizontalScope())
73
+ {
74
+ if (GUILayout.Button("Save"))
75
+ {
76
+ SquidHubUploadSettings.Key = _key;
77
+ SquidHubUploadSettings.Endpoint = string.IsNullOrWhiteSpace(_endpoint)
78
+ ? "https://publish.robotsquid.dev"
79
+ : _endpoint;
80
+ Close();
81
+ }
82
+ if (GUILayout.Button("Clear key"))
83
+ {
84
+ SquidHubUploadSettings.Key = "";
85
+ _key = "";
86
+ }
87
+ }
88
+
89
+ if (!string.IsNullOrEmpty(_key) && !_key.StartsWith("shu_"))
90
+ {
91
+ EditorGUILayout.HelpBox(
92
+ "That does not look like an upload key — they begin with shu_. " +
93
+ "A console session cookie will not work here.",
94
+ MessageType.Warning);
95
+ }
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: 205543f3c7718670401e28d4a55209ec
3
+ MonoImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ defaultReferences: []
7
+ executionOrder: 0
8
+ icon: {instanceID: 0}
9
+ userData:
10
+ assetBundleName:
11
+ assetBundleVariant:
@@ -0,0 +1,231 @@
1
+ // SquidHub → Upload Build. The whole publish, from the Editor.
2
+ //
3
+ // ⚠️ Runs the same job API the console does: start, PUT each file, complete.
4
+ // Only the authentication differs — a per-game upload key instead of a session
5
+ // cookie — so a build pushed from here is validated by exactly the same server
6
+ // code as one dragged into the console.
7
+ //
8
+ // ⚠️ Thin ON PURPOSE. Everything with real logic lives in SquidHubBuildScan,
9
+ // which has no UnityEditor dependency and is unit-tested under plain dotnet.
10
+ // This file is plumbing, and plumbing is what cannot be tested here.
11
+ using System;
12
+ using System.Collections;
13
+ using System.IO;
14
+ using System.Text;
15
+ using System.Text.RegularExpressions;
16
+ using UnityEditor;
17
+ using UnityEngine;
18
+ using UnityEngine.Networking;
19
+
20
+ namespace SquidHub.Editor
21
+ {
22
+ internal static class SquidHubUploader
23
+ {
24
+ private static IEnumerator _routine;
25
+
26
+ [MenuItem("SquidHub/Upload Build…", priority = 11)]
27
+ private static void UploadBuild()
28
+ {
29
+ if (!SquidHubUploadSettings.HasKey)
30
+ {
31
+ EditorUtility.DisplayDialog("SquidHub",
32
+ "No upload key set. Create one in the publisher console under your game → " +
33
+ "Upload keys, then paste it into SquidHub → Upload Settings.", "OK");
34
+ SquidHubUploadSettingsWindow.GetWindow<SquidHubUploadSettingsWindow>();
35
+ return;
36
+ }
37
+
38
+ var start = SquidHubUploadSettings.BuildFolder;
39
+ var folder = EditorUtility.OpenFolderPanel(
40
+ "Select the WebGL build folder (containing index.html)",
41
+ string.IsNullOrEmpty(start) ? "" : start, "");
42
+ if (string.IsNullOrEmpty(folder)) return;
43
+ SquidHubUploadSettings.BuildFolder = folder;
44
+
45
+ BuildScan scan;
46
+ try
47
+ {
48
+ scan = SquidHubBuildScan.Scan(folder);
49
+ }
50
+ catch (Exception e)
51
+ {
52
+ EditorUtility.DisplayDialog("SquidHub", "Could not read that folder:\n\n" + e.Message, "OK");
53
+ return;
54
+ }
55
+
56
+ // ⚠️ Refusals stop the upload before a byte moves. Every one of them
57
+ // is something the server would reject anyway; finding out here
58
+ // saves the publisher the round trip and names the fix.
59
+ if (scan.Refusals.Count > 0)
60
+ {
61
+ EditorUtility.DisplayDialog("SquidHub — build not uploadable",
62
+ string.Join("\n\n", scan.Refusals), "OK");
63
+ return;
64
+ }
65
+
66
+ if (!EditorUtility.DisplayDialog("SquidHub",
67
+ $"Upload {scan.Files.Count} files ({scan.TotalBytes / 1024f / 1024f:F1} MB)?" +
68
+ (scan.Skipped.Count > 0
69
+ ? $"\n\n{scan.Skipped.Count} OS metadata file(s) will be skipped."
70
+ : ""),
71
+ "Upload", "Cancel"))
72
+ {
73
+ return;
74
+ }
75
+
76
+ Run(Upload(scan));
77
+ }
78
+
79
+ // ⚠️ Driven by EditorApplication.update rather than blocking. Sleeping
80
+ // the main thread would stop UnityWebRequest progressing at all, and the
81
+ // Editor would appear to hang for the length of the upload.
82
+ private static void Run(IEnumerator routine)
83
+ {
84
+ _routine = routine;
85
+ EditorApplication.update -= Pump;
86
+ EditorApplication.update += Pump;
87
+ }
88
+
89
+ private static void Pump()
90
+ {
91
+ try
92
+ {
93
+ if (_routine != null && _routine.MoveNext()) return;
94
+ }
95
+ catch (Exception e)
96
+ {
97
+ EditorUtility.ClearProgressBar();
98
+ Debug.LogError("[SquidHub] upload failed: " + e);
99
+ }
100
+ _routine = null;
101
+ EditorApplication.update -= Pump;
102
+ EditorUtility.ClearProgressBar();
103
+ }
104
+
105
+ private static IEnumerator Upload(BuildScan scan)
106
+ {
107
+ var endpoint = SquidHubUploadSettings.Endpoint;
108
+ var key = SquidHubUploadSettings.Key;
109
+
110
+ EditorUtility.DisplayProgressBar("SquidHub", "Registering build…", 0f);
111
+
112
+ var startBody = "{\"intent\":\"start\",\"files\":" + SquidHubBuildScan.FilesJson(scan.Files) + "}";
113
+ string startResponse = null;
114
+ yield return Post(endpoint + "/api/ingest", key, startBody, r => startResponse = r);
115
+
116
+ if (startResponse == null) yield break;
117
+
118
+ if (Field(startResponse, "alreadyPublished") == "true")
119
+ {
120
+ EditorUtility.ClearProgressBar();
121
+ EditorUtility.DisplayDialog("SquidHub",
122
+ "These exact bytes are already published as " + Field(startResponse, "buildHash") +
123
+ ". Nothing to upload.", "OK");
124
+ yield break;
125
+ }
126
+
127
+ var jobId = Field(startResponse, "jobId");
128
+ if (string.IsNullOrEmpty(jobId))
129
+ {
130
+ Fail("The server did not start a build job", startResponse);
131
+ yield break;
132
+ }
133
+
134
+ for (var i = 0; i < scan.Files.Count; i++)
135
+ {
136
+ var file = scan.Files[i];
137
+ EditorUtility.DisplayProgressBar("SquidHub",
138
+ $"Uploading {i + 1}/{scan.Files.Count} — {file.Path}",
139
+ (float)i / scan.Files.Count);
140
+
141
+ var request = new UnityWebRequest(endpoint + "/api/ingest/upload", "PUT");
142
+ request.uploadHandler = new UploadHandlerRaw(File.ReadAllBytes(file.FullPath));
143
+ request.downloadHandler = new DownloadHandlerBuffer();
144
+ request.SetRequestHeader("authorization", "Bearer " + key);
145
+ request.SetRequestHeader("x-squidhub-job", jobId);
146
+ request.SetRequestHeader("x-squidhub-path", file.Path);
147
+ request.SetRequestHeader("x-squidhub-sha256", file.Sha256);
148
+
149
+ var op = request.SendWebRequest();
150
+ while (!op.isDone) yield return null;
151
+
152
+ if (request.result != UnityWebRequest.Result.Success)
153
+ {
154
+ Fail("Upload failed on " + file.Path, request.downloadHandler.text);
155
+ yield break;
156
+ }
157
+ }
158
+
159
+ EditorUtility.DisplayProgressBar("SquidHub", "Verifying and registering…", 0.99f);
160
+
161
+ string doneResponse = null;
162
+ yield return Post(endpoint + "/api/ingest",
163
+ key,
164
+ "{\"intent\":\"complete\",\"jobId\":" + SquidHubBuildScan.JsonString(jobId) + "}",
165
+ r => doneResponse = r);
166
+
167
+ EditorUtility.ClearProgressBar();
168
+ if (doneResponse == null) yield break;
169
+
170
+ if (Field(doneResponse, "ok") != "true")
171
+ {
172
+ Fail("The server refused the build", doneResponse);
173
+ yield break;
174
+ }
175
+
176
+ // ⚠️ Registered, NOT live. Promoting a build to players is a separate,
177
+ // deliberate act in the console — saying "uploaded" without saying
178
+ // that invites someone to assume their players already have it.
179
+ EditorUtility.DisplayDialog("SquidHub",
180
+ "Registered " + Field(doneResponse, "buildHash") + ".\n\n" +
181
+ "It is not live yet — promote it in the publisher console when you are ready.",
182
+ "OK");
183
+ }
184
+
185
+ private static IEnumerator Post(string url, string key, string body, Action<string> onOk)
186
+ {
187
+ var request = new UnityWebRequest(url, "POST");
188
+ request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
189
+ request.downloadHandler = new DownloadHandlerBuffer();
190
+ request.SetRequestHeader("authorization", "Bearer " + key);
191
+ request.SetRequestHeader("content-type", "application/json");
192
+
193
+ var op = request.SendWebRequest();
194
+ while (!op.isDone) yield return null;
195
+
196
+ if (request.responseCode == 401)
197
+ {
198
+ Fail("The upload key was refused",
199
+ "Check it in SquidHub → Upload Settings, and that it has not been revoked.");
200
+ yield break;
201
+ }
202
+ if (request.result != UnityWebRequest.Result.Success)
203
+ {
204
+ Fail("Request failed", request.error + "\n" + request.downloadHandler.text);
205
+ yield break;
206
+ }
207
+ onOk(request.downloadHandler.text);
208
+ }
209
+
210
+ private static void Fail(string title, string detail)
211
+ {
212
+ EditorUtility.ClearProgressBar();
213
+ Debug.LogError("[SquidHub] " + title + ": " + detail);
214
+ EditorUtility.DisplayDialog("SquidHub — " + title, detail, "OK");
215
+ }
216
+
217
+ /// <summary>
218
+ /// ⚠️ A regex, not a JSON parser, and only for the four flat fields this
219
+ /// flow reads. Shipping a parser in a package that already asks the
220
+ /// project to bring its own would be a second, worse one to maintain.
221
+ /// </summary>
222
+ private static string Field(string json, string name)
223
+ {
224
+ var match = Regex.Match(json, "\"" + Regex.Escape(name) + "\"\\s*:\\s*(\"([^\"]*)\"|true|false|[0-9]+)");
225
+ if (!match.Success) return null;
226
+ return match.Groups[2].Success && match.Value.Contains("\"" + match.Groups[2].Value + "\"")
227
+ ? match.Groups[2].Value
228
+ : match.Groups[1].Value;
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: c34878fbb310a03e1c5ddc1aefe730ac
3
+ MonoImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ defaultReferences: []
7
+ executionOrder: 0
8
+ icon: {instanceID: 0}
9
+ userData:
10
+ assetBundleName:
11
+ assetBundleVariant:
package/README.md CHANGED
@@ -82,6 +82,29 @@ Then push the folder:
82
82
  node src/cli.mjs push --game <slug> --dir <BuildFolder> --env=dev --verify
83
83
  ```
84
84
 
85
+ ## Uploading from Unity
86
+
87
+ `SquidHub → Upload Build…` does the whole publish — no console, no command line,
88
+ no repository checkout.
89
+
90
+ **Once:** in the publisher console open your game → **Upload keys** → create one,
91
+ then paste it into `SquidHub → Upload Settings`.
92
+
93
+ ⚠️ **The key never enters your project.** It lives in EditorPrefs, per machine
94
+ and per project path, so it cannot reach your repository. It can do exactly one
95
+ thing: upload a build for the one game it was minted for — it cannot read
96
+ anything, publish to a channel, or touch another game. If it leaks, revoke it in
97
+ the console; the worst it could have done is leave an unwanted build somewhere
98
+ no player can see.
99
+
100
+ ⚠️ **Uploading is not publishing.** The build arrives registered and not live.
101
+ Promoting it to players stays a deliberate act in the console.
102
+
103
+ The upload is refused before a byte moves if the build is precompressed or has
104
+ no `index.html` at its top level, and OS metadata is dropped rather than sent —
105
+ which is also what makes a build pushed from Unity hash identically to the same
106
+ bytes dragged into the console.
107
+
85
108
  ## In the Editor
86
109
 
87
110
  `Sdk` compiles on every platform and reports `Status() == "failed"` anywhere
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.robotsquid.squidhub",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "displayName": "SquidHub",
5
5
  "description": "Cloud saves, analytics and the coin economy for Unity WebGL games published on SquidHub. Installs the C# wrapper, the .jslib half and the page bridge, and rewrites the exported index.html so it survives the platform's CSP.",
6
6
  "unity": "2021.3",