com.robotsquid.squidhub 0.5.4 → 0.5.6

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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.6
4
+
5
+ ⚠️ **Fixes an upload that did nothing and said nothing.** `SquidHub → Upload
6
+ Build…` in 0.5.5 ran, reported no error, and uploaded no files.
7
+
8
+ `yield return SomeOtherIEnumerator()` requires the coroutine driver to DESCEND
9
+ into the nested routine. Unity's own runner does; the hand-rolled one here did
10
+ not — it treated the nested iterator as an ordinary yielded value, discarded it,
11
+ and carried on. So the request that starts a build never executed, the response
12
+ stayed null, and the routine exited quietly on a null check.
13
+
14
+ The driver is now `SquidHubPump`, which has no `UnityEditor` dependency and is
15
+ unit-tested for exactly that nesting rule, three levels deep. The JSON field
16
+ reader moved into the tested file too, since a null that looks like an answer is
17
+ the same failure in a different costume, and the run now logs what it is doing
18
+ so a silent no-op cannot recur.
19
+
20
+ ## 0.5.5
21
+
22
+ **Upload a build straight from Unity.** `SquidHub → Upload Build…` picks the
23
+ build folder, checks it, and pushes it to SquidHub — no console, no command
24
+ line, no repository checkout.
25
+
26
+ Set it up once in `SquidHub → Upload Settings` with a key from the publisher
27
+ console (your game → **Upload keys**).
28
+
29
+ ⚠️ **The key is stored in EditorPrefs, never in your project.** It is per-machine
30
+ and per-project-path, so there is no path by which it reaches your repository —
31
+ a better guarantee than being told not to commit it. A key can do exactly one
32
+ thing: upload a build for the one game it was minted for. It cannot read
33
+ anything, publish to a channel, or touch another game.
34
+
35
+ ⚠️ **Uploading is not publishing.** A build arrives registered and not live;
36
+ promoting it to players stays a deliberate act in the console.
37
+
38
+ The build is refused before anything uploads if it is precompressed, or has no
39
+ `index.html` at its top level. `.DS_Store` and friends are dropped rather than
40
+ uploaded — which is also what makes a Unity-pushed build hash identically to the
41
+ same bytes pushed by `squidhub-release` or dragged into the console.
42
+
3
43
  ## 0.5.4
4
44
 
5
45
  **No functional change from 0.5.3.**
@@ -0,0 +1,188 @@
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
+ /// <summary>
145
+ /// Reads one flat field out of a JSON response.
146
+ ///
147
+ /// ⚠️ A regex, not a parser, and deliberately only for the handful of
148
+ /// flat fields this flow reads. Shipping a parser in a package that
149
+ /// already asks the project to bring its own would be a second, worse
150
+ /// one to maintain.
151
+ ///
152
+ /// ⚠️ Returns null when absent, and the caller must treat that as a
153
+ /// failure rather than a value. Every silent no-op in this uploader has
154
+ /// come from a null that looked like an answer.
155
+ /// </summary>
156
+ public static string JsonField(string json, string name)
157
+ {
158
+ if (string.IsNullOrEmpty(json)) return null;
159
+ var match = System.Text.RegularExpressions.Regex.Match(
160
+ json,
161
+ "\"" + System.Text.RegularExpressions.Regex.Escape(name) +
162
+ "\"\\s*:\\s*(?:\"([^\"]*)\"|(true|false|-?[0-9.]+))");
163
+ if (!match.Success) return null;
164
+ return match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
165
+ }
166
+
167
+ internal static string JsonString(string value)
168
+ {
169
+ var sb = new System.Text.StringBuilder("\"");
170
+ foreach (var c in value)
171
+ {
172
+ switch (c)
173
+ {
174
+ case '"': sb.Append("\\\""); break;
175
+ case '\\': sb.Append("\\\\"); break;
176
+ case '\n': sb.Append("\\n"); break;
177
+ case '\r': sb.Append("\\r"); break;
178
+ case '\t': sb.Append("\\t"); break;
179
+ default:
180
+ if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4"));
181
+ else sb.Append(c);
182
+ break;
183
+ }
184
+ }
185
+ return sb.Append('"').ToString();
186
+ }
187
+ }
188
+ }
@@ -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,53 @@
1
+ // Running a coroutine outside a Unity player loop.
2
+ //
3
+ // ⚠️ Pure System.* — no UnityEditor — so the nesting rule below is covered by
4
+ // tests under plain dotnet. It exists because getting this wrong produced a
5
+ // completely silent failure: the upload menu item ran, reported nothing, and
6
+ // uploaded nothing.
7
+ using System;
8
+ using System.Collections;
9
+ using System.Collections.Generic;
10
+
11
+ namespace SquidHub.Editor
12
+ {
13
+ /// <summary>
14
+ /// A coroutine driver that understands NESTED iterators.
15
+ ///
16
+ /// ⚠️ `yield return SomeOtherIEnumerator()` is the whole reason this is not
17
+ /// three lines. Unity's own runner descends into a yielded IEnumerator and
18
+ /// runs it to completion before resuming the parent. A naive driver that
19
+ /// only calls MoveNext() on the outer routine treats the nested one as an
20
+ /// ordinary value, discards it, and carries straight on — so the nested work
21
+ /// NEVER RUNS and nothing anywhere reports a problem.
22
+ /// </summary>
23
+ internal sealed class SquidHubPump
24
+ {
25
+ private readonly Stack<IEnumerator> _stack = new Stack<IEnumerator>();
26
+
27
+ public SquidHubPump(IEnumerator routine)
28
+ {
29
+ _stack.Push(routine);
30
+ }
31
+
32
+ public bool Done => _stack.Count == 0;
33
+
34
+ /// <summary>Advances by one step. Returns false once everything has finished.</summary>
35
+ public bool Step()
36
+ {
37
+ if (_stack.Count == 0) return false;
38
+
39
+ var current = _stack.Peek();
40
+ if (!current.MoveNext())
41
+ {
42
+ // ⚠️ Pop and return true, not false. The PARENT still has work
43
+ // left; reporting "done" because a child finished would abandon
44
+ // the rest of the upload silently.
45
+ _stack.Pop();
46
+ return _stack.Count > 0;
47
+ }
48
+
49
+ if (current.Current is IEnumerator nested) _stack.Push(nested);
50
+ return true;
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: 4950b7b2580a1a30f9940f07fe016efc
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,232 @@
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 SquidHubPump _pump;
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
+ //
83
+ // ⚠️ SquidHubPump, not a bare MoveNext(). `yield return SomeIEnumerator()`
84
+ // must DESCEND into the nested routine — a driver that does not simply
85
+ // discards it and carries on, which made the entire upload run, report
86
+ // nothing and do nothing. That nesting rule is unit-tested.
87
+ private static void Run(IEnumerator routine)
88
+ {
89
+ _pump = new SquidHubPump(routine);
90
+ EditorApplication.update -= Pump;
91
+ EditorApplication.update += Pump;
92
+ }
93
+
94
+ private static void Pump()
95
+ {
96
+ try
97
+ {
98
+ if (_pump != null && _pump.Step()) return;
99
+ }
100
+ catch (Exception e)
101
+ {
102
+ EditorUtility.ClearProgressBar();
103
+ Debug.LogError("[SquidHub] upload failed: " + e);
104
+ EditorUtility.DisplayDialog("SquidHub — upload failed", e.Message, "OK");
105
+ }
106
+ _pump = null;
107
+ EditorApplication.update -= Pump;
108
+ EditorUtility.ClearProgressBar();
109
+ Debug.Log("[SquidHub] upload run finished.");
110
+ }
111
+
112
+ private static IEnumerator Upload(BuildScan scan)
113
+ {
114
+ var endpoint = SquidHubUploadSettings.Endpoint;
115
+ var key = SquidHubUploadSettings.Key;
116
+
117
+ Debug.Log($"[SquidHub] uploading {scan.Files.Count} file(s) to {endpoint}");
118
+ EditorUtility.DisplayProgressBar("SquidHub", "Registering build…", 0f);
119
+
120
+ var startBody = "{\"intent\":\"start\",\"files\":" + SquidHubBuildScan.FilesJson(scan.Files) + "}";
121
+ string startResponse = null;
122
+ yield return Post(endpoint + "/api/ingest", key, startBody, r => startResponse = r);
123
+
124
+ // ⚠️ Post reports its own failure and leaves this null. Returning
125
+ // quietly here is what made a failed start look like success.
126
+ if (startResponse == null)
127
+ {
128
+ Debug.LogError("[SquidHub] the start request returned nothing — upload abandoned.");
129
+ yield break;
130
+ }
131
+
132
+ if (SquidHubBuildScan.JsonField(startResponse, "alreadyPublished") == "true")
133
+ {
134
+ EditorUtility.ClearProgressBar();
135
+ EditorUtility.DisplayDialog("SquidHub",
136
+ "These exact bytes are already published as " + SquidHubBuildScan.JsonField(startResponse, "buildHash") +
137
+ ". Nothing to upload.", "OK");
138
+ yield break;
139
+ }
140
+
141
+ var jobId = SquidHubBuildScan.JsonField(startResponse, "jobId");
142
+ if (string.IsNullOrEmpty(jobId))
143
+ {
144
+ Fail("The server did not start a build job", startResponse);
145
+ yield break;
146
+ }
147
+
148
+ for (var i = 0; i < scan.Files.Count; i++)
149
+ {
150
+ var file = scan.Files[i];
151
+ EditorUtility.DisplayProgressBar("SquidHub",
152
+ $"Uploading {i + 1}/{scan.Files.Count} — {file.Path}",
153
+ (float)i / scan.Files.Count);
154
+
155
+ var request = new UnityWebRequest(endpoint + "/api/ingest/upload", "PUT");
156
+ request.uploadHandler = new UploadHandlerRaw(File.ReadAllBytes(file.FullPath));
157
+ request.downloadHandler = new DownloadHandlerBuffer();
158
+ request.SetRequestHeader("authorization", "Bearer " + key);
159
+ request.SetRequestHeader("x-squidhub-job", jobId);
160
+ request.SetRequestHeader("x-squidhub-path", file.Path);
161
+ request.SetRequestHeader("x-squidhub-sha256", file.Sha256);
162
+
163
+ var op = request.SendWebRequest();
164
+ while (!op.isDone) yield return null;
165
+
166
+ if (request.result != UnityWebRequest.Result.Success)
167
+ {
168
+ Fail("Upload failed on " + file.Path, request.downloadHandler.text);
169
+ yield break;
170
+ }
171
+ }
172
+
173
+ EditorUtility.DisplayProgressBar("SquidHub", "Verifying and registering…", 0.99f);
174
+
175
+ string doneResponse = null;
176
+ yield return Post(endpoint + "/api/ingest",
177
+ key,
178
+ "{\"intent\":\"complete\",\"jobId\":" + SquidHubBuildScan.JsonString(jobId) + "}",
179
+ r => doneResponse = r);
180
+
181
+ EditorUtility.ClearProgressBar();
182
+ if (doneResponse == null) yield break;
183
+
184
+ if (SquidHubBuildScan.JsonField(doneResponse, "ok") != "true")
185
+ {
186
+ Fail("The server refused the build", doneResponse);
187
+ yield break;
188
+ }
189
+
190
+ // ⚠️ Registered, NOT live. Promoting a build to players is a separate,
191
+ // deliberate act in the console — saying "uploaded" without saying
192
+ // that invites someone to assume their players already have it.
193
+ EditorUtility.DisplayDialog("SquidHub",
194
+ "Registered " + SquidHubBuildScan.JsonField(doneResponse, "buildHash") + ".\n\n" +
195
+ "It is not live yet — promote it in the publisher console when you are ready.",
196
+ "OK");
197
+ }
198
+
199
+ private static IEnumerator Post(string url, string key, string body, Action<string> onOk)
200
+ {
201
+ var request = new UnityWebRequest(url, "POST");
202
+ request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
203
+ request.downloadHandler = new DownloadHandlerBuffer();
204
+ request.SetRequestHeader("authorization", "Bearer " + key);
205
+ request.SetRequestHeader("content-type", "application/json");
206
+
207
+ var op = request.SendWebRequest();
208
+ while (!op.isDone) yield return null;
209
+
210
+ if (request.responseCode == 401)
211
+ {
212
+ Fail("The upload key was refused",
213
+ "Check it in SquidHub → Upload Settings, and that it has not been revoked.");
214
+ yield break;
215
+ }
216
+ if (request.result != UnityWebRequest.Result.Success)
217
+ {
218
+ Fail("Request failed", request.error + "\n" + request.downloadHandler.text);
219
+ yield break;
220
+ }
221
+ onOk(request.downloadHandler.text);
222
+ }
223
+
224
+ private static void Fail(string title, string detail)
225
+ {
226
+ EditorUtility.ClearProgressBar();
227
+ Debug.LogError("[SquidHub] " + title + ": " + detail);
228
+ EditorUtility.DisplayDialog("SquidHub — " + title, detail, "OK");
229
+ }
230
+
231
+ }
232
+ }
@@ -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.6",
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",