com.robotsquid.squidhub 0.5.0
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/Bridge~/squidhub-bridge.js +4 -0
- package/CHANGELOG.md +24 -0
- package/Editor/RobotSquid.SquidHub.Editor.asmdef +18 -0
- package/Editor/RobotSquid.SquidHub.Editor.asmdef.meta +7 -0
- package/Editor/SquidHubAudit.cs +125 -0
- package/Editor/SquidHubAudit.cs.meta +11 -0
- package/Editor/SquidHubBuildProcessor.cs +115 -0
- package/Editor/SquidHubBuildProcessor.cs.meta +11 -0
- package/Editor/SquidHubHtml.cs +126 -0
- package/Editor/SquidHubHtml.cs.meta +11 -0
- package/Editor/SquidHubMenu.cs +43 -0
- package/Editor/SquidHubMenu.cs.meta +11 -0
- package/Editor/SquidHubPackage.cs +48 -0
- package/Editor/SquidHubPackage.cs.meta +11 -0
- package/Editor.meta +8 -0
- package/LICENSE.md +21 -0
- package/README.md +148 -0
- package/Runtime/Plugins/WebGL/SquidHub.jslib +67 -0
- package/Runtime/Plugins/WebGL/SquidHub.jslib.meta +32 -0
- package/Runtime/Plugins/WebGL.meta +8 -0
- package/Runtime/Plugins.meta +8 -0
- package/Runtime/RobotSquid.SquidHub.asmdef +14 -0
- package/Runtime/RobotSquid.SquidHub.asmdef.meta +7 -0
- package/Runtime/SquidHub.cs +247 -0
- package/Runtime/SquidHub.cs.meta +11 -0
- package/Runtime.meta +8 -0
- package/package.json +38 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source: engine/bridge-entry.js. Regenerate: npm run generate
|
|
3
|
+
// Installs window.SquidHub. Load it BEFORE your engine's bootstrap.
|
|
4
|
+
"use strict";(()=>{var h="squidhub",C=["https://robotsquid.dev","https://robotsquid.com"],N=["saves.get","saves.put","analytics.track","commerce.purchase"];var s=class extends Error{code;retryAfter;constructor(e,t,r){super(t,r?.cause!==void 0?{cause:r.cause}:void 0),this.name="SquidHubError",this.code=e,r?.retryAfter!==void 0&&(this.retryAfter=r.retryAfter)}},g=class extends s{serverCopy;constructor(e){super("CONFLICT","save version conflict \u2014 another device wrote first"),this.name="SaveConflictError",this.serverCopy=e}};var I=new Set,v=!1,R=!1,k=null,H=null;function M(n){try{n.gain.value=v?0:1}catch{}}function x(n){n.querySelectorAll("audio, video").forEach(e=>{e.muted=v})}function V(n=window){if(R)return;R=!0;let e=["AudioContext","webkitAudioContext"];for(let t of e){let r=n[t];if(typeof r!="function")continue;let o=function(...c){let a=new r(...c);try{let y=a.destination,l=a.createGain();l.connect(y),M(l),I.add(l),Object.defineProperty(a,"destination",{configurable:!0,get:()=>l})}catch{}return a};o.prototype=r.prototype,n[t]=o}typeof n.MutationObserver=="function"&&n.document&&(k=new n.MutationObserver(()=>x(n.document)),k.observe(n.document.documentElement,{childList:!0,subtree:!0}))}function P(n){v=n;for(let e of I)M(e);typeof document<"u"&&x(document);try{H?.(v)}catch{}}function $(n){if(!n)throw new s("UNTRUSTED_EMBEDDER","no referrer \u2014 the game must be embedded by the SquidHub portal");let e;try{e=new URL(n).origin}catch(t){throw new s("UNTRUSTED_EMBEDDER",`malformed referrer: ${n}`,{cause:t})}if(!C.includes(e))throw new s("UNTRUSTED_EMBEDDER",`embedder ${e} is not a SquidHub portal \u2014 refusing to hand shake`);return e}function j(n){let t=new URLSearchParams(n.replace(/^#/,"")).get("s");if(!t)throw new s("PROTOCOL","missing launch nonce \u2014 the game was not opened through the SquidHub portal");return t}var A=class{session;#n;#e=new Map;#o=0;#r;constructor(e,t,r){this.session=e,this.#n=t,this.#r=r,t.onmessage=o=>this.#s(o.data)}get connected(){return this.#n!==null}can(e){return this.session.capabilities.includes(e)}close(){for(let[,e]of this.#e)clearTimeout(e.timer),e.reject(new s("NOT_CONNECTED","SDK closed"));this.#e.clear(),this.#n?.close(),this.#n=null}#s(e){if(typeof e!="object"||e===null)return;let t=e;if(t.t===`${h}.audio`&&typeof t.muted=="boolean"){P(t.muted);return}if(typeof t.id!="string")return;let r=this.#e.get(t.id);if(!r)return;if(this.#e.delete(t.id),clearTimeout(r.timer),t.ok===!0){r.resolve(t.r);return}let o=t.e??{};r.reject(new s(o.code??"PROTOCOL",o.message??"request failed",o.retryAfter!==void 0?{retryAfter:o.retryAfter}:void 0))}#t(e,t){let r=this.#n;if(!r)return Promise.reject(new s("NOT_CONNECTED","SDK is not connected"));let o=`r${++this.#o}`;return new Promise((c,a)=>{let y=setTimeout(()=>{this.#e.delete(o),a(new s("TIMEOUT",`${e} timed out`))},this.#r);this.#e.set(o,{resolve:c,reject:a,timer:y}),r.postMessage({id:o,v:1,t:e,p:t})})}saves={get:()=>this.#t("saves.get",{}),put:async(e,t)=>{if(e.byteLength>8388608)throw new s("INVALID",`save is ${e.byteLength} bytes, over the ${8388608} limit`);try{return await this.#t("saves.put",{data:e,...t?.ifVersion!==void 0?{ifVersion:t.ifVersion}:{}})}catch(r){if(r instanceof s&&r.code==="CONFLICT"){let o=await this.saves.get();if(o)throw new g(o)}throw r}}};analytics={track:(e,t)=>{this.#t("analytics.track",{name:e,props:t}).catch(()=>{})}};commerce={purchase:(e,t)=>this.#t("commerce.purchase",{sku:e,idempotencyKey:t})}};async function D(n={},e=globalThis.window){let t=n.handshakeTimeoutMs??15e3,r=n.requestTimeoutMs??2e4;V(e);let o=$(e.document.referrer),c=j(e.location.hash),a=n.helloRetryMs??150;return new Promise((y,l)=>{let p,m=()=>{clearTimeout(b),p!==void 0&&clearInterval(p),e.removeEventListener("message",i)},b=setTimeout(()=>{m(),l(new s("TIMEOUT","portal did not complete the handshake"))},t);function i(u){if(u.origin!==o)return;let f=u.data;if(!f||f.t!==`${h}.ready`||f.v!==1)return;let w=u.ports[0];w&&(m(),w.start?.(),y(new A(f.session,w,r)))}e.addEventListener("message",i);let d=()=>e.parent.postMessage({t:`${h}.hello`,v:1,nonce:c},o);d(),p=setInterval(d,a)})}var B="SquidHub",_=32768;function G(n){let e="";for(let t=0;t<n.length;t+=_)e+=String.fromCharCode(...n.subarray(t,t+_));return btoa(e)}function J(n){let e=atob(n),t=new Uint8Array(e.length);for(let r=0;r<e.length;r++)t[r]=e.charCodeAt(r);return t}function O(n){return n instanceof Uint8Array?G(n):Array.isArray(n)?n.map(O):n&&typeof n=="object"?Object.fromEntries(Object.entries(n).map(([e,t])=>[e,O(t)])):n}function F(n,e){let t=n[e];if(typeof t!="number"||!Number.isInteger(t))throw new s("INVALID",`${e} must be an integer, got ${JSON.stringify(t)}`);return t}function E(n,e){let t=n[e];if(typeof t!="string")throw new s("INVALID",`${e} must be a string, got ${JSON.stringify(t)}`);return t}function W(n,e){try{return J(E(n,e))}catch(t){throw new s("INVALID",`${e} is not valid base64`,{cause:t})}}function K(n,e){let t=n[e];if(t==null)return;if(typeof t!="object"||Array.isArray(t))throw new s("INVALID",`${e} must be an object`);let r={};for(let[o,c]of Object.entries(t)){if(typeof c!="string"&&typeof c!="number"&&typeof c!="boolean")throw new s("INVALID",`${e}.${o} must be a string, number or boolean`);r[o]=c}return r}var X={"saves.get":n=>n.saves.get(),"saves.put":(n,e)=>n.saves.put(W(e,"data"),e.ifVersion===void 0?void 0:{ifVersion:F(e,"ifVersion")}),"analytics.track":(n,e)=>(n.analytics.track(E(e,"name"),K(e,"props")),null),"commerce.purchase":(n,e)=>n.commerce.purchase(E(e,"sku"),E(e,"idempotencyKey"))};function Y(n){return N.includes(n)}function U(n){return n instanceof s?n.code:"PROTOCOL"}function z(n){let e=n instanceof s?n:void 0;return JSON.stringify({ok:!1,code:U(n),message:n instanceof Error?n.message:String(n),...e?.retryAfter!==void 0?{retryAfter:e.retryAfter}:{},...n instanceof g?{serverCopy:O(n.serverCopy)}:{}})}function T(n={},e=globalThis){let t=null,r="connecting",o="",c=D(n).then(i=>(t=i,r="ready",i),i=>{throw r="failed",o=`${U(i)}: ${i instanceof Error?i.message:String(i)}`,i});c.catch(()=>{});let a=new Map,y=0,l=64;function p(i,d){if(a.size>=l){let u=a.keys().next();u.done||a.delete(u.value)}a.set(i,d)}async function m(i,d){try{if(!Y(i))throw new s("INVALID",`unknown verb ${i}`);let u;try{u=JSON.parse(d||"{}")??{}}catch(q){throw new s("INVALID","payload is not valid JSON",{cause:q})}let f=await c,w=await X[i](f,u);return JSON.stringify({ok:!0,value:O(w)??null})}catch(u){return z(u)}}let b={status:()=>r,error:()=>o,session:()=>t?JSON.stringify(t.session):"",invoke(i,d="{}"){let u=`c${++y}`;return m(i,d).then(f=>p(u,f)),u},notify(i,d="{}"){m(i,d)},poll(i){let d=a.get(i);return d===void 0?"":(a.delete(i),d)},close(){t?.close()}};return e[B]=b,b}T();})();
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
First release. Wraps `@squidhub/game-sdk` 0.5.0.
|
|
6
|
+
|
|
7
|
+
- `SquidHub.Sdk` — saves, analytics and commerce from C#, with the `.jslib`
|
|
8
|
+
half included and linked for WebGL.
|
|
9
|
+
- The page bridge ships prebuilt. No npm, no esbuild, no bundling step.
|
|
10
|
+
- A post-build step rewrites the exported `index.html` so it survives the
|
|
11
|
+
platform's CSP: every inline script is hoisted to an external file and the
|
|
12
|
+
bridge is injected ahead of the Unity loader. Works with the stock Default
|
|
13
|
+
and Minimal templates — there is no template to choose.
|
|
14
|
+
- A pre-build step refuses builds that cannot run on the platform (pthreads on,
|
|
15
|
+
`.jslib` not linked, bridge missing) instead of letting them fail in a
|
|
16
|
+
browser twenty minutes later.
|
|
17
|
+
- ⚠️ **Save conflicts are now resolvable from Unity.** The `.jslib` flattened an
|
|
18
|
+
error to code + message and discarded the rest, so a `CONFLICT` arrived in C#
|
|
19
|
+
with no server copy — leaving a Unity game unable to merge, which is the one
|
|
20
|
+
thing the platform cannot do on its behalf. `SquidHubException.ServerCopy`
|
|
21
|
+
carries it now.
|
|
22
|
+
- `SquidHub.Sdk` now compiles and no-ops outside WebGL, so pressing Play no
|
|
23
|
+
longer throws `EntryPointNotFoundException`. It reports failure; it never
|
|
24
|
+
fakes a session.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "RobotSquid.SquidHub.Editor",
|
|
3
|
+
"rootNamespace": "SquidHub.Editor",
|
|
4
|
+
"references": [
|
|
5
|
+
"RobotSquid.SquidHub"
|
|
6
|
+
],
|
|
7
|
+
"includePlatforms": [
|
|
8
|
+
"Editor"
|
|
9
|
+
],
|
|
10
|
+
"excludePlatforms": [],
|
|
11
|
+
"allowUnsafeCode": false,
|
|
12
|
+
"overrideReferences": false,
|
|
13
|
+
"precompiledReferences": [],
|
|
14
|
+
"autoReferenced": true,
|
|
15
|
+
"defineConstraints": [],
|
|
16
|
+
"versionDefines": [],
|
|
17
|
+
"noEngineReferences": false
|
|
18
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Everything that must be true before a build can run on SquidHub.
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ Each of these fails QUIETLY at runtime if it is wrong: a missing bridge is
|
|
4
|
+
// a handshake that times out after fifteen seconds, an unlinked .jslib is a
|
|
5
|
+
// DllImport that resolves to nothing, and pthreads is a SharedArrayBuffer error
|
|
6
|
+
// from deep inside the engine. Catching them at build time is the whole point
|
|
7
|
+
// of this file.
|
|
8
|
+
using System.Collections.Generic;
|
|
9
|
+
using System.Linq;
|
|
10
|
+
using UnityEditor;
|
|
11
|
+
using UnityEditor.Build;
|
|
12
|
+
|
|
13
|
+
namespace SquidHub.Editor
|
|
14
|
+
{
|
|
15
|
+
internal sealed class Finding
|
|
16
|
+
{
|
|
17
|
+
public bool Fatal;
|
|
18
|
+
public string Message;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
internal static class SquidHubAudit
|
|
22
|
+
{
|
|
23
|
+
/// <summary>Escape hatch, added to the WebGL scripting define symbols.</summary>
|
|
24
|
+
public const string AllowThreadsDefine = "SQUIDHUB_ALLOW_THREADS";
|
|
25
|
+
|
|
26
|
+
public static List<Finding> Run()
|
|
27
|
+
{
|
|
28
|
+
var findings = new List<Finding>();
|
|
29
|
+
|
|
30
|
+
if (SquidHubPackage.FindBridge() == null)
|
|
31
|
+
{
|
|
32
|
+
Fatal(findings,
|
|
33
|
+
"squidhub-bridge.js is missing from the package (Bridge~/). The build " +
|
|
34
|
+
"would ship with no window.SquidHub, and every game would hang on the " +
|
|
35
|
+
"handshake for 15 seconds and then fail. Reinstall the package.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
CheckJslib(findings);
|
|
39
|
+
CheckThreads(findings);
|
|
40
|
+
CheckCompression(findings);
|
|
41
|
+
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ⚠️ A .jslib that is not marked compatible with WebGL is not an error
|
|
46
|
+
// Unity reports in any useful way — the DllImports simply do not link.
|
|
47
|
+
// Assert on the importer rather than trusting the .meta we shipped.
|
|
48
|
+
private static void CheckJslib(List<Finding> findings)
|
|
49
|
+
{
|
|
50
|
+
var importer = AssetImporter.GetAtPath(SquidHubPackage.JslibAssetPath) as PluginImporter;
|
|
51
|
+
if (importer == null)
|
|
52
|
+
{
|
|
53
|
+
Warn(findings,
|
|
54
|
+
"Could not read the plugin importer for " + SquidHubPackage.JslibAssetPath +
|
|
55
|
+
". If the build fails to link SquidHubInvoke, select that file and tick " +
|
|
56
|
+
"WebGL under Platform settings.");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!importer.GetCompatibleWithPlatform(BuildTarget.WebGL))
|
|
61
|
+
{
|
|
62
|
+
Fatal(findings,
|
|
63
|
+
"SquidHub.jslib is not marked compatible with WebGL, so the " +
|
|
64
|
+
"[DllImport(\"__Internal\")] entry points will not link. Select " +
|
|
65
|
+
SquidHubPackage.JslibAssetPath + " and tick WebGL under Platform settings.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private static void CheckThreads(List<Finding> findings)
|
|
70
|
+
{
|
|
71
|
+
if (!PlayerSettings.WebGL.threadsSupport) return;
|
|
72
|
+
|
|
73
|
+
if (AllowsThreads())
|
|
74
|
+
{
|
|
75
|
+
Warn(findings,
|
|
76
|
+
"pthreads is enabled and " + AllowThreadsDefine + " is set. This build " +
|
|
77
|
+
"needs SharedArrayBuffer, so it can only run on the isolated tier — which " +
|
|
78
|
+
"carries no ads. Talk to your SquidHub representative before shipping it.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Fatal(findings,
|
|
83
|
+
"pthreads is enabled (Player Settings > WebGL > Enable Native C/C++ " +
|
|
84
|
+
"Multithreading). SquidHub's default tier is not cross-origin isolated, so " +
|
|
85
|
+
"SharedArrayBuffer does not exist and this build cannot start. Turn it off. " +
|
|
86
|
+
"If you genuinely need threads, ask about the isolated tier and add " +
|
|
87
|
+
AllowThreadsDefine + " to the WebGL scripting define symbols to silence this.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private static void CheckCompression(List<Finding> findings)
|
|
91
|
+
{
|
|
92
|
+
var format = PlayerSettings.WebGL.compressionFormat;
|
|
93
|
+
|
|
94
|
+
if (format == WebGLCompressionFormat.Disabled)
|
|
95
|
+
{
|
|
96
|
+
Warn(findings,
|
|
97
|
+
"Compression is disabled. squidhub-release records Content-Encoding from " +
|
|
98
|
+
"the file suffix at upload, so Brotli costs you nothing at runtime and " +
|
|
99
|
+
"saves the player most of the download.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ⚠️ Not an error — just weight. The platform serves the correct
|
|
103
|
+
// Content-Encoding, so the JS decompressor never has to run.
|
|
104
|
+
if (PlayerSettings.WebGL.decompressionFallback)
|
|
105
|
+
{
|
|
106
|
+
Warn(findings,
|
|
107
|
+
"Decompression Fallback is on. squidhub-release strips the .br suffix and " +
|
|
108
|
+
"records Content-Encoding: br in R2, so the browser decompresses natively " +
|
|
109
|
+
"and the bundled JS decompressor is dead weight. Turning it off is safe here.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private static bool AllowsThreads()
|
|
114
|
+
{
|
|
115
|
+
var defines = PlayerSettings.GetScriptingDefineSymbols(NamedBuildTarget.WebGL);
|
|
116
|
+
return defines.Split(';').Select(d => d.Trim()).Contains(AllowThreadsDefine);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private static void Fatal(List<Finding> list, string message) =>
|
|
120
|
+
list.Add(new Finding { Fatal = true, Message = message });
|
|
121
|
+
|
|
122
|
+
private static void Warn(List<Finding> list, string message) =>
|
|
123
|
+
list.Add(new Finding { Fatal = false, Message = message });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The whole of the integration, from the publisher's point of view: install the
|
|
2
|
+
// package, press Build, get something the platform will serve.
|
|
3
|
+
//
|
|
4
|
+
// The pre-build half refuses builds that cannot possibly run. The post-build
|
|
5
|
+
// half writes the bridge in and makes the HTML CSP-safe.
|
|
6
|
+
using System.IO;
|
|
7
|
+
using System.Linq;
|
|
8
|
+
using System.Text;
|
|
9
|
+
using UnityEditor;
|
|
10
|
+
using UnityEditor.Build;
|
|
11
|
+
using UnityEditor.Build.Reporting;
|
|
12
|
+
using UnityEngine;
|
|
13
|
+
|
|
14
|
+
namespace SquidHub.Editor
|
|
15
|
+
{
|
|
16
|
+
/// <summary>Refuses a build that could not run on SquidHub, before it is made.</summary>
|
|
17
|
+
internal sealed class SquidHubBuildPreprocessor : IPreprocessBuildWithReport
|
|
18
|
+
{
|
|
19
|
+
public int callbackOrder => 0;
|
|
20
|
+
|
|
21
|
+
public void OnPreprocessBuild(BuildReport report)
|
|
22
|
+
{
|
|
23
|
+
if (report.summary.platform != BuildTarget.WebGL) return;
|
|
24
|
+
|
|
25
|
+
var findings = SquidHubAudit.Run();
|
|
26
|
+
foreach (var finding in findings.Where(f => !f.Fatal))
|
|
27
|
+
Debug.LogWarning("SquidHub: " + finding.Message);
|
|
28
|
+
|
|
29
|
+
var fatal = findings.Where(f => f.Fatal).ToList();
|
|
30
|
+
if (fatal.Count == 0) return;
|
|
31
|
+
|
|
32
|
+
var message = new StringBuilder("SquidHub: this build cannot run on the platform.\n");
|
|
33
|
+
foreach (var f in fatal) message.Append("\n • ").Append(f.Message).Append('\n');
|
|
34
|
+
|
|
35
|
+
// ⚠️ Throwing here fails the build. That is the point: every one of
|
|
36
|
+
// these produces a game that loads and then does nothing, which is
|
|
37
|
+
// far more expensive to diagnose than a refused build.
|
|
38
|
+
throw new BuildFailedException(message.ToString());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// <summary>Installs the bridge and rewrites index.html so the CSP will run it.</summary>
|
|
43
|
+
internal sealed class SquidHubBuildPostprocessor : IPostprocessBuildWithReport
|
|
44
|
+
{
|
|
45
|
+
public int callbackOrder => 0;
|
|
46
|
+
|
|
47
|
+
public void OnPostprocessBuild(BuildReport report)
|
|
48
|
+
{
|
|
49
|
+
if (report.summary.platform != BuildTarget.WebGL) return;
|
|
50
|
+
|
|
51
|
+
var outDir = report.summary.outputPath;
|
|
52
|
+
if (string.IsNullOrEmpty(outDir) || !Directory.Exists(outDir))
|
|
53
|
+
{
|
|
54
|
+
Debug.LogError("SquidHub: build output directory not found at '" + outDir +
|
|
55
|
+
"'. The bridge was NOT installed and this build will not run.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var indexPath = Path.Combine(outDir, "index.html");
|
|
60
|
+
if (!File.Exists(indexPath))
|
|
61
|
+
{
|
|
62
|
+
Debug.LogError("SquidHub: no index.html in the build output. The bridge was NOT " +
|
|
63
|
+
"installed and this build will not run.");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
var log = new StringBuilder("SquidHub: WebGL build prepared\n");
|
|
68
|
+
|
|
69
|
+
if (!InstallBridge(outDir, log)) return;
|
|
70
|
+
|
|
71
|
+
var result = SquidHubHtml.Apply(File.ReadAllText(indexPath), SquidHubPackage.BridgeFileName);
|
|
72
|
+
|
|
73
|
+
foreach (var extracted in result.Extracted)
|
|
74
|
+
{
|
|
75
|
+
File.WriteAllText(Path.Combine(outDir, extracted.Key), extracted.Value);
|
|
76
|
+
log.Append(" ✓ hoisted an inline script → ").Append(extracted.Key).Append('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (result.Extracted.Count == 0)
|
|
80
|
+
log.Append(" · no inline scripts to hoist\n");
|
|
81
|
+
|
|
82
|
+
File.WriteAllText(indexPath, result.Html);
|
|
83
|
+
foreach (var note in result.Notes) log.Append(" ✓ ").Append(note).Append('\n');
|
|
84
|
+
|
|
85
|
+
log.Append("\nPush it with:\n node src/cli.mjs push --game <slug> --dir ")
|
|
86
|
+
.Append(outDir).Append(" --env=dev --verify");
|
|
87
|
+
|
|
88
|
+
Debug.Log(log.ToString());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private static bool InstallBridge(string outDir, StringBuilder log)
|
|
92
|
+
{
|
|
93
|
+
var source = SquidHubPackage.FindBridge();
|
|
94
|
+
if (source == null)
|
|
95
|
+
{
|
|
96
|
+
Debug.LogError("SquidHub: squidhub-bridge.js is missing from the package. This " +
|
|
97
|
+
"build has no window.SquidHub and every handshake will time out.");
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var destination = Path.Combine(outDir, SquidHubPackage.BridgeFileName);
|
|
102
|
+
File.Copy(source, destination, true);
|
|
103
|
+
|
|
104
|
+
// ⚠️ Files in Library/PackageCache are commonly read-only, and
|
|
105
|
+
// File.Copy carries the attribute across. Left alone, the NEXT build
|
|
106
|
+
// into this directory fails on the overwrite.
|
|
107
|
+
var attributes = File.GetAttributes(destination);
|
|
108
|
+
if ((attributes & FileAttributes.ReadOnly) != 0)
|
|
109
|
+
File.SetAttributes(destination, attributes & ~FileAttributes.ReadOnly);
|
|
110
|
+
|
|
111
|
+
log.Append(" ✓ wrote ").Append(SquidHubPackage.BridgeFileName).Append('\n');
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Rewrites an exported index.html into one the platform's CSP will actually run.
|
|
2
|
+
//
|
|
3
|
+
// squidhub-gamehost serves `script-src 'self' 'wasm-unsafe-eval' blob:` with no
|
|
4
|
+
// 'unsafe-inline'. Unity's templates — including the stock Default and Minimal
|
|
5
|
+
// ones — inline their bootstrap, and the failure is silent: the page loads, all
|
|
6
|
+
// the markup is there, and nothing runs. The only signal is a CSP violation in
|
|
7
|
+
// the console.
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ Pure text in, pure text out, no UnityEditor types. That is deliberate: it
|
|
10
|
+
// is the one piece of this package with real logic, so it stays readable and
|
|
11
|
+
// runnable outside a build.
|
|
12
|
+
using System;
|
|
13
|
+
using System.Collections.Generic;
|
|
14
|
+
using System.Text.RegularExpressions;
|
|
15
|
+
|
|
16
|
+
namespace SquidHub.Editor
|
|
17
|
+
{
|
|
18
|
+
internal static class SquidHubHtml
|
|
19
|
+
{
|
|
20
|
+
internal sealed class Result
|
|
21
|
+
{
|
|
22
|
+
public string Html;
|
|
23
|
+
public bool Changed;
|
|
24
|
+
/// <summary>Filename → contents, for each inline block that was hoisted out.</summary>
|
|
25
|
+
public readonly List<KeyValuePair<string, string>> Extracted =
|
|
26
|
+
new List<KeyValuePair<string, string>>();
|
|
27
|
+
public readonly List<string> Notes = new List<string>();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private static readonly Regex ScriptTag = new Regex(
|
|
31
|
+
@"<script\b([^>]*)>(.*?)</script\s*>",
|
|
32
|
+
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant);
|
|
33
|
+
|
|
34
|
+
private static readonly Regex SrcAttr = new Regex(
|
|
35
|
+
@"\bsrc\s*=", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
|
36
|
+
|
|
37
|
+
private static readonly Regex TypeAttr = new Regex(
|
|
38
|
+
@"\btype\s*=\s*(""([^""]*)""|'([^']*)'|([^\s>]+))",
|
|
39
|
+
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
|
40
|
+
|
|
41
|
+
// ⚠️ Anything NOT in here is data, not code — `application/json`,
|
|
42
|
+
// `text/template` and friends are never executed, so CSP does not care
|
|
43
|
+
// and hoisting them would break the page that reads them by id.
|
|
44
|
+
private static readonly HashSet<string> ExecutableTypes =
|
|
45
|
+
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
46
|
+
{
|
|
47
|
+
"", "module",
|
|
48
|
+
"text/javascript", "application/javascript",
|
|
49
|
+
"text/ecmascript", "application/ecmascript",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
public static Result Apply(string html, string bridgeFileName)
|
|
53
|
+
{
|
|
54
|
+
var result = new Result { Html = html };
|
|
55
|
+
|
|
56
|
+
// ⚠️ Idempotent. A rebuild into a directory that was already
|
|
57
|
+
// processed must not stack a second bridge tag or renumber the
|
|
58
|
+
// hoisted files out from under the HTML that references them.
|
|
59
|
+
if (html.IndexOf(bridgeFileName, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
60
|
+
{
|
|
61
|
+
result.Notes.Add("already processed — left alone");
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var index = 0;
|
|
66
|
+
var rewritten = ScriptTag.Replace(html, match =>
|
|
67
|
+
{
|
|
68
|
+
var attrs = match.Groups[1].Value;
|
|
69
|
+
var body = match.Groups[2].Value;
|
|
70
|
+
|
|
71
|
+
if (SrcAttr.IsMatch(attrs)) return match.Value; // already external
|
|
72
|
+
if (string.IsNullOrWhiteSpace(body)) return match.Value; // nothing to hoist
|
|
73
|
+
if (!IsExecutable(attrs)) return match.Value; // data, not code
|
|
74
|
+
|
|
75
|
+
var name = "squidhub-inline-" + index++ + ".js";
|
|
76
|
+
result.Extracted.Add(new KeyValuePair<string, string>(name, body));
|
|
77
|
+
|
|
78
|
+
// ⚠️ Attributes are carried across verbatim. Dropping a
|
|
79
|
+
// `type="module"` here would turn a deferred module into a
|
|
80
|
+
// classic script and silently reorder execution.
|
|
81
|
+
var carried = attrs.TrimEnd();
|
|
82
|
+
return "<script" + carried + " src=\"" + name + "\"></script>";
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
var tag = "<script src=\"" + bridgeFileName + "\"></script>";
|
|
86
|
+
result.Html = InsertBridge(rewritten, tag, result);
|
|
87
|
+
result.Changed = !ReferenceEquals(result.Html, html) && result.Html != html;
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private static bool IsExecutable(string attrs)
|
|
92
|
+
{
|
|
93
|
+
var m = TypeAttr.Match(attrs);
|
|
94
|
+
if (!m.Success) return true; // no type attribute at all == classic script
|
|
95
|
+
var value = m.Groups[2].Success ? m.Groups[2].Value
|
|
96
|
+
: m.Groups[3].Success ? m.Groups[3].Value
|
|
97
|
+
: m.Groups[4].Value;
|
|
98
|
+
return ExecutableTypes.Contains(value.Trim());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ⚠️ Before the FIRST script in the document, not merely before the
|
|
102
|
+
// loader. installBridge() patches AudioContext.destination on the
|
|
103
|
+
// instance, so it only catches contexts created after it runs — land
|
|
104
|
+
// after the engine boots and the portal's mute button is inert.
|
|
105
|
+
private static string InsertBridge(string html, string tag, Result result)
|
|
106
|
+
{
|
|
107
|
+
var at = html.IndexOf("<script", StringComparison.OrdinalIgnoreCase);
|
|
108
|
+
if (at >= 0)
|
|
109
|
+
{
|
|
110
|
+
result.Notes.Add("bridge injected before the first script");
|
|
111
|
+
return html.Substring(0, at) + tag + "\n " + html.Substring(at);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
foreach (var close in new[] { "</head>", "</body>" })
|
|
115
|
+
{
|
|
116
|
+
at = html.IndexOf(close, StringComparison.OrdinalIgnoreCase);
|
|
117
|
+
if (at < 0) continue;
|
|
118
|
+
result.Notes.Add("no script tags found — bridge injected before " + close);
|
|
119
|
+
return html.Substring(0, at) + " " + tag + "\n" + html.Substring(at);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
result.Notes.Add("no script tags and no </head> or </body> — bridge prepended");
|
|
123
|
+
return tag + "\n" + html;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The same audit the build runs, on demand — so a publisher can find out what is
|
|
2
|
+
// wrong without waiting for a WebGL build to finish first.
|
|
3
|
+
using System.Linq;
|
|
4
|
+
using UnityEditor;
|
|
5
|
+
using UnityEngine;
|
|
6
|
+
|
|
7
|
+
namespace SquidHub.Editor
|
|
8
|
+
{
|
|
9
|
+
internal static class SquidHubMenu
|
|
10
|
+
{
|
|
11
|
+
[MenuItem("SquidHub/Check WebGL Settings", priority = 0)]
|
|
12
|
+
private static void Check()
|
|
13
|
+
{
|
|
14
|
+
var findings = SquidHubAudit.Run();
|
|
15
|
+
if (findings.Count == 0)
|
|
16
|
+
{
|
|
17
|
+
EditorUtility.DisplayDialog("SquidHub",
|
|
18
|
+
"Ready to build.\n\nThe bridge is present, SquidHub.jslib is linked for " +
|
|
19
|
+
"WebGL, and nothing in Player Settings would stop this game running on " +
|
|
20
|
+
"the platform.", "OK");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
foreach (var finding in findings)
|
|
25
|
+
{
|
|
26
|
+
if (finding.Fatal) Debug.LogError("SquidHub: " + finding.Message);
|
|
27
|
+
else Debug.LogWarning("SquidHub: " + finding.Message);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var fatal = findings.Count(f => f.Fatal);
|
|
31
|
+
EditorUtility.DisplayDialog("SquidHub",
|
|
32
|
+
fatal > 0
|
|
33
|
+
? fatal + " problem(s) would fail the build, and " + (findings.Count - fatal) +
|
|
34
|
+
" warning(s) are worth reading. Details are in the Console."
|
|
35
|
+
: findings.Count + " warning(s). Details are in the Console.",
|
|
36
|
+
"OK");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[MenuItem("SquidHub/Documentation", priority = 20)]
|
|
40
|
+
private static void Documentation() =>
|
|
41
|
+
Application.OpenURL("https://www.npmjs.com/package/@squidhub/game-sdk");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Locating our own files on disk, from Editor code.
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ The bridge lives in `Bridge~/`, and the trailing tilde is load-bearing:
|
|
4
|
+
// Unity ignores such folders entirely, so the file is never imported, never
|
|
5
|
+
// given a .meta, and — the actual reason — never handed to Unity's own
|
|
6
|
+
// JavaScript handling. It ships in the package like any other file; it simply
|
|
7
|
+
// is not an asset. That means it must be read from DISK, not the AssetDatabase.
|
|
8
|
+
using System.IO;
|
|
9
|
+
using UnityEditor.PackageManager;
|
|
10
|
+
|
|
11
|
+
namespace SquidHub.Editor
|
|
12
|
+
{
|
|
13
|
+
internal static class SquidHubPackage
|
|
14
|
+
{
|
|
15
|
+
public const string Name = "com.robotsquid.squidhub";
|
|
16
|
+
|
|
17
|
+
/// <summary>The bridge script's path on disk, or null if it is missing.</summary>
|
|
18
|
+
public static string FindBridge()
|
|
19
|
+
{
|
|
20
|
+
foreach (var root in Roots())
|
|
21
|
+
{
|
|
22
|
+
if (string.IsNullOrEmpty(root)) continue;
|
|
23
|
+
var candidate = Path.Combine(root, "Bridge~", BridgeFileName);
|
|
24
|
+
if (File.Exists(candidate)) return candidate;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// <summary>What the bridge is called once it is sitting in a build.</summary>
|
|
30
|
+
public const string BridgeFileName = "squidhub-bridge.js";
|
|
31
|
+
|
|
32
|
+
/// <summary>The .jslib's AssetDatabase path — a package path, not an Assets one.</summary>
|
|
33
|
+
public const string JslibAssetPath =
|
|
34
|
+
"Packages/" + Name + "/Runtime/Plugins/WebGL/SquidHub.jslib";
|
|
35
|
+
|
|
36
|
+
// ⚠️ Two roots, because resolvedPath covers a registry install (which
|
|
37
|
+
// lives in the global package cache) while the literal Packages/ path
|
|
38
|
+
// covers an embedded or local one. Neither alone covers both.
|
|
39
|
+
private static string[] Roots()
|
|
40
|
+
{
|
|
41
|
+
string resolved = null;
|
|
42
|
+
var info = PackageInfo.FindForAssembly(typeof(SquidHubPackage).Assembly);
|
|
43
|
+
if (info != null) resolved = info.resolvedPath;
|
|
44
|
+
|
|
45
|
+
return new[] { resolved, Path.GetFullPath(Path.Combine("Packages", Name)) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/Editor.meta
ADDED
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) RobotSquid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# SquidHub for Unity
|
|
2
|
+
|
|
3
|
+
Cloud saves, analytics and the coin economy for Unity WebGL games on
|
|
4
|
+
[SquidHub](https://robotsquid.dev).
|
|
5
|
+
|
|
6
|
+
Install it, press Build. There is no bundling step, no WebGL template to pick
|
|
7
|
+
and nothing to copy into `Assets/`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Add the registry to `Packages/manifest.json`:
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
{
|
|
15
|
+
"scopedRegistries": [
|
|
16
|
+
{
|
|
17
|
+
"name": "RobotSquid",
|
|
18
|
+
"url": "https://registry.npmjs.org",
|
|
19
|
+
"scopes": ["com.robotsquid"]
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then **Window > Package Manager > My Registries > SquidHub > Install**.
|
|
26
|
+
|
|
27
|
+
Unity 2021.3 or newer.
|
|
28
|
+
|
|
29
|
+
## Use it
|
|
30
|
+
|
|
31
|
+
```csharp
|
|
32
|
+
using System.Collections.Generic;
|
|
33
|
+
using SquidHub;
|
|
34
|
+
|
|
35
|
+
async void Start()
|
|
36
|
+
{
|
|
37
|
+
await Sdk.WaitForReadyAsync(); // throws if the portal refuses
|
|
38
|
+
|
|
39
|
+
var json = await Sdk.SavesGetAsync(); // JSON; parse with your own library
|
|
40
|
+
// `data` inside it is base64 — Sdk.FromBase64(...) gives you the bytes.
|
|
41
|
+
|
|
42
|
+
Sdk.AnalyticsTrack("level_done", new Dictionary<string, object> { ["level"] = 3 });
|
|
43
|
+
|
|
44
|
+
await Sdk.SavesPutAsync(bytes, ifVersion: version);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Responses come back as JSON strings deliberately: your project already carries a
|
|
49
|
+
JSON library, and a second one shipped in here would be a worse one.
|
|
50
|
+
|
|
51
|
+
The full API — capabilities, save conflicts, error codes, the manifest — is
|
|
52
|
+
documented in [`@squidhub/game-sdk`](https://www.npmjs.com/package/@squidhub/game-sdk).
|
|
53
|
+
This package is a wrapper around it and shares its behaviour exactly.
|
|
54
|
+
|
|
55
|
+
⚠️ **Handle `CONFLICT` on `SavesPutAsync`.** It is not an edge case — it is what
|
|
56
|
+
happens whenever a player has your game open in two places, which is the
|
|
57
|
+
situation cloud saves exist for. The server's copy rides along on the error so
|
|
58
|
+
you can merge without a second round trip. The platform stores opaque bytes and
|
|
59
|
+
cannot merge for you.
|
|
60
|
+
|
|
61
|
+
## What happens when you build
|
|
62
|
+
|
|
63
|
+
Nothing to configure. On a WebGL build this package:
|
|
64
|
+
|
|
65
|
+
1. **Refuses the build** if it could not possibly run — pthreads enabled, the
|
|
66
|
+
`.jslib` not linked for WebGL, the bridge missing. Each of those otherwise
|
|
67
|
+
fails silently in a browser rather than loudly in the Editor.
|
|
68
|
+
2. **Writes `squidhub-bridge.js`** into the output and injects it ahead of the
|
|
69
|
+
Unity loader.
|
|
70
|
+
3. **Hoists every inline `<script>`** out of `index.html` into an external file.
|
|
71
|
+
|
|
72
|
+
Step 3 is the one that matters. `squidhub-gamehost` serves
|
|
73
|
+
`script-src 'self' 'wasm-unsafe-eval' blob:` with **no `'unsafe-inline'`**, and
|
|
74
|
+
every stock Unity template inlines its bootstrap. Left alone, the page loads,
|
|
75
|
+
all the markup is there, and nothing runs — the only signal is a CSP violation
|
|
76
|
+
in the console. This is the single most common first-integration failure, and
|
|
77
|
+
you should never see it.
|
|
78
|
+
|
|
79
|
+
Then push the folder:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
node src/cli.mjs push --game <slug> --dir <BuildFolder> --env=dev --verify
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## In the Editor
|
|
86
|
+
|
|
87
|
+
`Sdk` compiles on every platform and reports `Status() == "failed"` anywhere
|
|
88
|
+
that is not a WebGL build — pressing Play does not throw.
|
|
89
|
+
|
|
90
|
+
⚠️ **It never simulates a session.** There is no offline player, no fake save
|
|
91
|
+
and no pretend balance, because a game built against a player who cannot exist
|
|
92
|
+
gets its first honest answer from production. Your game must start when a
|
|
93
|
+
platform call fails; treat every verb as something that can fail and keep a
|
|
94
|
+
local fallback.
|
|
95
|
+
|
|
96
|
+
Run **SquidHub > Check WebGL Settings** any time to audit the project without
|
|
97
|
+
waiting for a build.
|
|
98
|
+
|
|
99
|
+
## Player Settings
|
|
100
|
+
|
|
101
|
+
The package checks these for you, but for reference:
|
|
102
|
+
|
|
103
|
+
| Setting | Value | Why |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| Native C/C++ Multithreading | **off** | SquidHub's default tier is not cross-origin isolated, so `SharedArrayBuffer` does not exist. A pthreads build cannot start. Ask about the isolated tier if you genuinely need threads — it carries no ads |
|
|
106
|
+
| Compression Format | Brotli | `squidhub-release` strips the `.br` suffix and records `Content-Encoding: br` in R2, so the browser decompresses natively |
|
|
107
|
+
| Decompression Fallback | off | The header is already correct, so the bundled JS decompressor is dead weight |
|
|
108
|
+
|
|
109
|
+
## Things that will otherwise bite
|
|
110
|
+
|
|
111
|
+
⚠️ **Your game only works when the portal launches it.** The launch nonce
|
|
112
|
+
arrives in the URL fragment (`#s=…`), so opening `index.html` directly, or
|
|
113
|
+
reloading without the fragment, fails the handshake with `UNTRUSTED_EMBEDDER`.
|
|
114
|
+
That is intended, not a bug to work around.
|
|
115
|
+
|
|
116
|
+
⚠️ **No external resources, at all.** The CSP is `default-src 'self' blob:
|
|
117
|
+
data:`. A CDN font, a remote texture or a third-party analytics snippet is
|
|
118
|
+
blocked at load and shows up as a console error, not as a missing style.
|
|
119
|
+
Everything must ship inside the build.
|
|
120
|
+
|
|
121
|
+
⚠️ **Do not write your own rotate prompt or call `screen.orientation.lock()`.**
|
|
122
|
+
Declare the orientations you support in your `manifest.json` and the portal
|
|
123
|
+
draws the prompt outside your frame. Two prompts fight, and yours is the one the
|
|
124
|
+
player sees first.
|
|
125
|
+
|
|
126
|
+
⚠️ **The mute button is the portal's, but the muting happens in your frame.** A
|
|
127
|
+
cross-origin iframe has no `muted` property, so the SDK interposes a gain node
|
|
128
|
+
on your `AudioContext`. It works without you doing anything — but only because
|
|
129
|
+
the bridge is injected ahead of the loader, which is why step 2 above is not
|
|
130
|
+
merely tidy. Declare `"sound": true` in your manifest or no button appears.
|
|
131
|
+
|
|
132
|
+
⚠️ **Editing `manifest.json` alone changes nothing.** It is excluded from the
|
|
133
|
+
build hash, so a manifest-only re-upload registers no new version and your edit
|
|
134
|
+
is discarded. Change a real file and rebuild.
|
|
135
|
+
|
|
136
|
+
## ⚠️ Status
|
|
137
|
+
|
|
138
|
+
**The Unity path has never been run against the platform.** The HTML rewriting
|
|
139
|
+
is unit-tested, the bridge is covered by the SDK's own suite, and the C#
|
|
140
|
+
compiles — but no Unity build has completed a handshake with the portal. The
|
|
141
|
+
JavaScript and Godot paths are tested; this one is not. Expect to shake out
|
|
142
|
+
problems, and tell us what you find.
|
|
143
|
+
|
|
144
|
+
## Support
|
|
145
|
+
|
|
146
|
+
Contact your SquidHub publisher representative.
|
|
147
|
+
|
|
148
|
+
MIT licensed.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source: squidhub-contracts/schemas.json (protocol v1)
|
|
3
|
+
// Regenerate: npm run generate
|
|
4
|
+
//
|
|
5
|
+
// Drop this in Assets/Plugins/WebGL/. Unity links [DllImport("__Internal")]
|
|
6
|
+
// against it at build time; without it the C# wrapper does not resolve.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ Strings returned to C# must be allocated in the Emscripten heap, because
|
|
9
|
+
// Unity frees them on the C# side. Returning a JS string hands over a pointer
|
|
10
|
+
// into nothing.
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ The result envelope is split HERE rather than in C#. A JSON parser is free
|
|
13
|
+
// in JavaScript and would be a liability in a generated C# file, so the parts
|
|
14
|
+
// cross as "ok\u001f<valueJson>" or "err\u001f<code>\u001f<message>".
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ Every function using the $-prefixed helper must declare it in __deps.
|
|
17
|
+
// Emscripten dead-strips JS-only library members otherwise, and the failure is
|
|
18
|
+
// a ReferenceError at runtime in an exported build — not at build time.
|
|
19
|
+
mergeInto(LibraryManager.library, {
|
|
20
|
+
SquidHubStatus__deps: ["$squidHubString"],
|
|
21
|
+
SquidHubStatus: function () {
|
|
22
|
+
return squidHubString(window.SquidHub ? window.SquidHub.status() : "failed");
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
SquidHubLastError__deps: ["$squidHubString"],
|
|
26
|
+
SquidHubLastError: function () {
|
|
27
|
+
return squidHubString(
|
|
28
|
+
window.SquidHub ? window.SquidHub.error() : "SquidHub bridge is not on the page",
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
SquidHubSession__deps: ["$squidHubString"],
|
|
33
|
+
SquidHubSession: function () {
|
|
34
|
+
return squidHubString(window.SquidHub ? window.SquidHub.session() : "");
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
SquidHubInvoke__deps: ["$squidHubString"],
|
|
38
|
+
SquidHubInvoke: function (verb, payload) {
|
|
39
|
+
return squidHubString(window.SquidHub.invoke(UTF8ToString(verb), UTF8ToString(payload)));
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
SquidHubNotify: function (verb, payload) {
|
|
43
|
+
if (window.SquidHub) window.SquidHub.notify(UTF8ToString(verb), UTF8ToString(payload));
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
SquidHubPoll__deps: ["$squidHubString"],
|
|
47
|
+
SquidHubPoll: function (id) {
|
|
48
|
+
var raw = window.SquidHub.poll(UTF8ToString(id));
|
|
49
|
+
if (!raw) return squidHubString("");
|
|
50
|
+
var res = JSON.parse(raw);
|
|
51
|
+
if (res.ok) return squidHubString("ok\u001f" + JSON.stringify(res.value === undefined ? null : res.value));
|
|
52
|
+
// ⚠️ The server's copy rides along on a save conflict and MUST cross with
|
|
53
|
+
// the error. Only the game can merge its own save format, so dropping it
|
|
54
|
+
// here left a Unity game with a CONFLICT it had no way to resolve — while
|
|
55
|
+
// the JS and Godot paths merged from the same envelope.
|
|
56
|
+
var parts = ["err", res.code || "PROTOCOL", res.message || "request failed"];
|
|
57
|
+
if (res.serverCopy !== undefined) parts.push(JSON.stringify(res.serverCopy));
|
|
58
|
+
return squidHubString(parts.join("\u001f"));
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
$squidHubString: function (str) {
|
|
62
|
+
var size = lengthBytesUTF8(str) + 1;
|
|
63
|
+
var buffer = _malloc(size);
|
|
64
|
+
stringToUTF8(str, buffer, size);
|
|
65
|
+
return buffer;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
fileFormatVersion: 2
|
|
2
|
+
guid: 2349bb197ece0d2ae59334e6a8c4e95a
|
|
3
|
+
PluginImporter:
|
|
4
|
+
externalObjects: {}
|
|
5
|
+
serializedVersion: 2
|
|
6
|
+
iconMap: {}
|
|
7
|
+
executionOrder: {}
|
|
8
|
+
defineConstraints: []
|
|
9
|
+
isPreloaded: 0
|
|
10
|
+
isOverridable: 0
|
|
11
|
+
isExplicitlyReferenced: 0
|
|
12
|
+
validateReferences: 1
|
|
13
|
+
platformData:
|
|
14
|
+
- first:
|
|
15
|
+
Any:
|
|
16
|
+
second:
|
|
17
|
+
enabled: 0
|
|
18
|
+
settings: {}
|
|
19
|
+
- first:
|
|
20
|
+
Editor: Editor
|
|
21
|
+
second:
|
|
22
|
+
enabled: 0
|
|
23
|
+
settings:
|
|
24
|
+
DefaultValueInitialized: true
|
|
25
|
+
- first:
|
|
26
|
+
WebGL: WebGL
|
|
27
|
+
second:
|
|
28
|
+
enabled: 1
|
|
29
|
+
settings: {}
|
|
30
|
+
userData:
|
|
31
|
+
assetBundleName:
|
|
32
|
+
assetBundleVariant:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "RobotSquid.SquidHub",
|
|
3
|
+
"rootNamespace": "SquidHub",
|
|
4
|
+
"references": [],
|
|
5
|
+
"includePlatforms": [],
|
|
6
|
+
"excludePlatforms": [],
|
|
7
|
+
"allowUnsafeCode": false,
|
|
8
|
+
"overrideReferences": false,
|
|
9
|
+
"precompiledReferences": [],
|
|
10
|
+
"autoReferenced": true,
|
|
11
|
+
"defineConstraints": [],
|
|
12
|
+
"versionDefines": [],
|
|
13
|
+
"noEngineReferences": false
|
|
14
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Source: squidhub-contracts/schemas.json (protocol v1)
|
|
3
|
+
// Regenerate: npm run generate
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// await Sdk.WaitForReadyAsync();
|
|
7
|
+
// var json = await Sdk.SavesGetAsync();
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ Requires SquidHub.jslib alongside it and a bundled entry script on the
|
|
10
|
+
// page calling installBridge() from @squidhub/game-sdk. The wrapper does not
|
|
11
|
+
// contain the protocol; it marshals to window.SquidHub. The
|
|
12
|
+
// com.robotsquid.squidhub UPM package ships all three and wires the page up at
|
|
13
|
+
// build time — prefer it to copying these files in by hand.
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ Only the WebGL player can reach the bridge. This compiles everywhere —
|
|
16
|
+
// see the stubs below — but reports "failed" off WebGL rather than pretending.
|
|
17
|
+
// Build without pthreads: SharedArrayBuffer requires cross-origin isolation,
|
|
18
|
+
// which SquidHub's default tier does not enable.
|
|
19
|
+
//
|
|
20
|
+
// ⚠️ UNVERIFIED. No Unity build exists in this workspace, so unlike the JS
|
|
21
|
+
// bridge — which is covered by the SDK's own suite — this path has never been
|
|
22
|
+
// run. Treat it as a starting point and cover it with an L4 run before
|
|
23
|
+
// promising it to a publisher.
|
|
24
|
+
//
|
|
25
|
+
// Responses come back as JSON strings for the project's own JSON library to
|
|
26
|
+
// read. Deliberate: Unity projects already carry one, and shipping a parser in
|
|
27
|
+
// a generated file would be a second, worse parser to maintain.
|
|
28
|
+
using System;
|
|
29
|
+
using System.Collections.Generic;
|
|
30
|
+
using System.Globalization;
|
|
31
|
+
using System.Runtime.InteropServices;
|
|
32
|
+
using System.Text;
|
|
33
|
+
using System.Threading.Tasks;
|
|
34
|
+
|
|
35
|
+
namespace SquidHub
|
|
36
|
+
{
|
|
37
|
+
public static class Sdk
|
|
38
|
+
{
|
|
39
|
+
#if UNITY_WEBGL && !UNITY_EDITOR
|
|
40
|
+
[DllImport("__Internal")] private static extern string SquidHubStatus();
|
|
41
|
+
[DllImport("__Internal")] private static extern string SquidHubLastError();
|
|
42
|
+
[DllImport("__Internal")] private static extern string SquidHubSession();
|
|
43
|
+
[DllImport("__Internal")] private static extern string SquidHubInvoke(string verb, string payloadJson);
|
|
44
|
+
[DllImport("__Internal")] private static extern void SquidHubNotify(string verb, string payloadJson);
|
|
45
|
+
[DllImport("__Internal")] private static extern string SquidHubPoll(string id);
|
|
46
|
+
#else
|
|
47
|
+
// ⚠️ Play mode, and every non-WebGL player, has no browser and no
|
|
48
|
+
// bridge. Without these stubs the DllImports above throw
|
|
49
|
+
// EntryPointNotFoundException the first time anyone presses Play, which
|
|
50
|
+
// reads as "the SDK is broken" rather than "there is no portal here".
|
|
51
|
+
//
|
|
52
|
+
// ⚠️ They report failure; they never simulate a session. A fake
|
|
53
|
+
// player would let a game be built against someone who cannot exist,
|
|
54
|
+
// and the first honest answer would arrive from production.
|
|
55
|
+
private const string NoBridge =
|
|
56
|
+
"SquidHub is only available in a WebGL build launched by the portal.";
|
|
57
|
+
|
|
58
|
+
private static string SquidHubStatus() => "failed";
|
|
59
|
+
private static string SquidHubLastError() => NoBridge;
|
|
60
|
+
private static string SquidHubSession() => "";
|
|
61
|
+
private static string SquidHubInvoke(string verb, string payloadJson) => "editor";
|
|
62
|
+
private static void SquidHubNotify(string verb, string payloadJson) { }
|
|
63
|
+
|
|
64
|
+
// ⚠️ Must be non-empty, or InvokeAsync spins forever on a poll that
|
|
65
|
+
// never settles. UNAVAILABLE is the contract code for "not served here".
|
|
66
|
+
private static string SquidHubPoll(string id) =>
|
|
67
|
+
"err\u001fUNAVAILABLE\u001f" + NoBridge;
|
|
68
|
+
#endif
|
|
69
|
+
|
|
70
|
+
private const char Separator = '\u001f';
|
|
71
|
+
|
|
72
|
+
/// <summary>Handshake state: connecting, ready or failed.</summary>
|
|
73
|
+
public static string Status() => SquidHubStatus();
|
|
74
|
+
|
|
75
|
+
/// <summary>SessionView as JSON, or "" until the handshake completes.</summary>
|
|
76
|
+
public static string Session() => SquidHubSession();
|
|
77
|
+
|
|
78
|
+
/// <summary>Resolves when the portal handshake completes; throws if it is refused.</summary>
|
|
79
|
+
public static async Task WaitForReadyAsync()
|
|
80
|
+
{
|
|
81
|
+
while (true)
|
|
82
|
+
{
|
|
83
|
+
var state = SquidHubStatus();
|
|
84
|
+
if (state == "ready") return;
|
|
85
|
+
if (state == "failed") throw new SquidHubException(SquidHubLastError(), "UNTRUSTED_EMBEDDER");
|
|
86
|
+
await Task.Yield();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ⚠️ Polled rather than awaited. The bridge is a JS promise; nothing on
|
|
91
|
+
// the C# side can await one, and a retained callback would have to
|
|
92
|
+
// survive a domain reload.
|
|
93
|
+
private static async Task<string> InvokeAsync(string verb, string payloadJson)
|
|
94
|
+
{
|
|
95
|
+
var id = SquidHubInvoke(verb, payloadJson);
|
|
96
|
+
while (true)
|
|
97
|
+
{
|
|
98
|
+
var raw = SquidHubPoll(id);
|
|
99
|
+
if (!string.IsNullOrEmpty(raw))
|
|
100
|
+
{
|
|
101
|
+
// "ok<US><valueJson>", or
|
|
102
|
+
// "err<US><code><US><message>[<US><serverCopyJson>]" — split
|
|
103
|
+
// by the jslib so this side never parses JSON.
|
|
104
|
+
//
|
|
105
|
+
// ⚠️ Bounded at 4 so a message that somehow contained the
|
|
106
|
+
// separator cannot shift the payload out of position.
|
|
107
|
+
var parts = raw.Split(new[] { Separator }, 4, StringSplitOptions.None);
|
|
108
|
+
if (parts[0] == "ok") return parts.Length > 1 ? parts[1] : "null";
|
|
109
|
+
throw new SquidHubException(
|
|
110
|
+
parts.Length > 2 ? parts[2] : "request failed",
|
|
111
|
+
parts.Length > 1 ? parts[1] : "PROTOCOL",
|
|
112
|
+
parts.Length > 3 ? parts[3] : null);
|
|
113
|
+
}
|
|
114
|
+
await Task.Yield();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// <summary>Base64 for the crossing only — the platform still stores raw bytes.</summary>
|
|
119
|
+
private static string ToBase64(byte[] bytes) => Convert.ToBase64String(bytes ?? Array.Empty<byte>());
|
|
120
|
+
|
|
121
|
+
/// <summary>Decodes a base64 field out of a response.</summary>
|
|
122
|
+
public static byte[] FromBase64(string b64) =>
|
|
123
|
+
string.IsNullOrEmpty(b64) ? Array.Empty<byte>() : Convert.FromBase64String(b64);
|
|
124
|
+
|
|
125
|
+
/// <summary>saves.get — capability: saves</summary>
|
|
126
|
+
/// <remarks>Returns the response as JSON. <c>data</c> is base64 — decode with FromBase64.</remarks>
|
|
127
|
+
/// <exception cref="SquidHubException">The platform refused the call; Code carries the reason.</exception>
|
|
128
|
+
public static Task<string> SavesGetAsync()
|
|
129
|
+
{
|
|
130
|
+
var payload = new Dictionary<string, object>();
|
|
131
|
+
return InvokeAsync("saves.get", JsonWriter.Write(payload));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// <summary>saves.put — capability: saves</summary>
|
|
135
|
+
/// <remarks>Returns the response as JSON, for the project's own JSON library to read.</remarks>
|
|
136
|
+
/// <exception cref="SquidHubException">The platform refused the call; Code carries the reason.</exception>
|
|
137
|
+
public static Task<string> SavesPutAsync(byte[] data, int? ifVersion = null)
|
|
138
|
+
{
|
|
139
|
+
var payload = new Dictionary<string, object>();
|
|
140
|
+
payload["data"] = ToBase64(data);
|
|
141
|
+
if (ifVersion != null) payload["ifVersion"] = ifVersion;
|
|
142
|
+
return InvokeAsync("saves.put", JsonWriter.Write(payload));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// <summary>analytics.track — capability: analytics</summary>
|
|
146
|
+
/// <remarks>Fire-and-forget by contract: never reports a failure.</remarks>
|
|
147
|
+
public static void AnalyticsTrack(string name, Dictionary<string, object> props = null)
|
|
148
|
+
{
|
|
149
|
+
var payload = new Dictionary<string, object>();
|
|
150
|
+
payload["name"] = name;
|
|
151
|
+
if (props != null) payload["props"] = props;
|
|
152
|
+
SquidHubNotify("analytics.track", JsonWriter.Write(payload));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// <summary>commerce.purchase — capability: commerce.spend</summary>
|
|
156
|
+
/// <remarks>Returns the response as JSON, for the project's own JSON library to read.</remarks>
|
|
157
|
+
/// <exception cref="SquidHubException">The platform refused the call; Code carries the reason.</exception>
|
|
158
|
+
public static Task<string> CommercePurchaseAsync(string sku, string idempotencyKey)
|
|
159
|
+
{
|
|
160
|
+
var payload = new Dictionary<string, object>();
|
|
161
|
+
payload["sku"] = sku;
|
|
162
|
+
payload["idempotencyKey"] = idempotencyKey;
|
|
163
|
+
return InvokeAsync("commerce.purchase", JsonWriter.Write(payload));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// <summary>
|
|
168
|
+
/// Writes the flat payloads this contract defines — primitives, plus one
|
|
169
|
+
/// level of nesting for analytics props. ⚠️ Not a general JSON writer, and
|
|
170
|
+
/// deliberately so: anything richer belongs in the project's own library.
|
|
171
|
+
/// </summary>
|
|
172
|
+
internal static class JsonWriter
|
|
173
|
+
{
|
|
174
|
+
public static string Write(Dictionary<string, object> fields)
|
|
175
|
+
{
|
|
176
|
+
var sb = new StringBuilder("{");
|
|
177
|
+
var first = true;
|
|
178
|
+
foreach (var kv in fields)
|
|
179
|
+
{
|
|
180
|
+
if (!first) sb.Append(',');
|
|
181
|
+
first = false;
|
|
182
|
+
WriteString(sb, kv.Key);
|
|
183
|
+
sb.Append(':');
|
|
184
|
+
WriteValue(sb, kv.Value);
|
|
185
|
+
}
|
|
186
|
+
return sb.Append('}').ToString();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private static void WriteValue(StringBuilder sb, object value)
|
|
190
|
+
{
|
|
191
|
+
switch (value)
|
|
192
|
+
{
|
|
193
|
+
case null: sb.Append("null"); break;
|
|
194
|
+
case string s: WriteString(sb, s); break;
|
|
195
|
+
case bool b: sb.Append(b ? "true" : "false"); break;
|
|
196
|
+
case Dictionary<string, object> d: sb.Append(Write(d)); break;
|
|
197
|
+
case IFormattable n: sb.Append(n.ToString(null, CultureInfo.InvariantCulture)); break;
|
|
198
|
+
default: WriteString(sb, value.ToString()); break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private static void WriteString(StringBuilder sb, string value)
|
|
203
|
+
{
|
|
204
|
+
sb.Append('"');
|
|
205
|
+
foreach (var c in value)
|
|
206
|
+
{
|
|
207
|
+
switch (c)
|
|
208
|
+
{
|
|
209
|
+
case '"': sb.Append("\\\""); break;
|
|
210
|
+
case '\\': sb.Append("\\\\"); break;
|
|
211
|
+
case '\n': sb.Append("\\n"); break;
|
|
212
|
+
case '\r': sb.Append("\\r"); break;
|
|
213
|
+
case '\t': sb.Append("\\t"); break;
|
|
214
|
+
default:
|
|
215
|
+
if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
|
|
216
|
+
else sb.Append(c);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
sb.Append('"');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
public class SquidHubException : Exception
|
|
225
|
+
{
|
|
226
|
+
/// <summary>Platform error code — RATE_LIMITED, CONFLICT, DENIED and so on.</summary>
|
|
227
|
+
public string Code { get; }
|
|
228
|
+
|
|
229
|
+
/// <summary>
|
|
230
|
+
/// On Code == "CONFLICT", the server's copy of the save as JSON —
|
|
231
|
+
/// <c>data</c> base64, alongside its <c>version</c>. Null otherwise.
|
|
232
|
+
/// </summary>
|
|
233
|
+
/// <remarks>
|
|
234
|
+
/// ⚠️ Merging is yours. The platform stores opaque bytes and cannot
|
|
235
|
+
/// merge for you, and a game that catches CONFLICT and writes anyway
|
|
236
|
+
/// loses a player's progress the first time they play on two devices.
|
|
237
|
+
/// </remarks>
|
|
238
|
+
public string ServerCopy { get; }
|
|
239
|
+
|
|
240
|
+
public SquidHubException(string message, string code = "PROTOCOL", string serverCopy = null)
|
|
241
|
+
: base(message)
|
|
242
|
+
{
|
|
243
|
+
Code = code;
|
|
244
|
+
ServerCopy = serverCopy;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
package/Runtime.meta
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "com.robotsquid.squidhub",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"displayName": "SquidHub",
|
|
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
|
+
"unity": "2021.3",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "RobotSquid",
|
|
10
|
+
"url": "https://robotsquid.dev"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"squidhub",
|
|
14
|
+
"webgl",
|
|
15
|
+
"sdk",
|
|
16
|
+
"games",
|
|
17
|
+
"cloud-saves"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/RobotSquid/SquidHub.git",
|
|
22
|
+
"directory": "squidhub-unity"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"Runtime",
|
|
30
|
+
"Runtime.meta",
|
|
31
|
+
"Editor",
|
|
32
|
+
"Editor.meta",
|
|
33
|
+
"Bridge~",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE.md"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {}
|
|
38
|
+
}
|