@playruo/embed 1.3.3 → 1.4.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/README.md CHANGED
@@ -17,6 +17,60 @@ start the experience inside the current iframe bounds:
17
17
  />
18
18
  ```
19
19
 
20
+ ## Campaigns with several operations
21
+
22
+ `operation` is the campaign path. When the campaign holds more than one
23
+ operation, name the one to open with `operationKey`, sent as `?op=`:
24
+
25
+ ```tsx
26
+ <PlayruoEmbed
27
+ operation="crypticalpath"
28
+ operationKey="crypticalpath-fr"
29
+ />
30
+ ```
31
+
32
+ Passing it inside `operation` (`"crypticalpath?op=..."`) still works and warns:
33
+ it was the only way before this prop existed. `operationKey` wins over it. Other
34
+ query parameters in `operation` are kept as they are.
35
+
36
+ ## Password protected operations
37
+
38
+ A protected operation renders no form of its own: it announces the gate and the
39
+ host collects the password. Read the gate from `renderState` and answer with the
40
+ actions:
41
+
42
+ ```tsx
43
+ <PlayruoEmbed
44
+ operation="crypticalpath"
45
+ onPasswordRequired={() => setAsking(true)}
46
+ onPasswordResult={({ success }) => setAsking(!success)}
47
+ renderState={({ password, actions }) =>
48
+ password.required && !password.unlocked ? (
49
+ <PasswordForm error={password.error} onSubmit={actions.submitPassword} />
50
+ ) : null
51
+ }
52
+ />
53
+ ```
54
+
55
+ The announce is emitted once, when the operation's configuration is loaded, so a
56
+ host that mounts later misses it: call `actions.requestPassword()` to have it
57
+ sent again. `password.checked` tells the two silences apart — an operation that
58
+ has not answered yet, and one that answered it is not protected.
59
+
60
+ `password.required` stays true after a successful unlock, so read
61
+ `password.unlocked` for the gate itself. A refusal carries a code in
62
+ `password.error`, typed as `PlayruoPasswordErrorCode`.
63
+
64
+ ## Message origin
65
+
66
+ Messages are only accepted from the iframe's own window **and** from the origin
67
+ `operation`/`baseUrl` point at, and every message sent to the iframe is
68
+ addressed to that same origin rather than to `"*"`. A playable served from an
69
+ origin other than the one the embed URL was built with is therefore ignored.
70
+
71
+ Events forwarded one level up, to the host page's own parent, still go to `"*"`:
72
+ that origin is not something this package can know.
73
+
20
74
  ## Event forwarding
21
75
 
22
76
  When the embedded Playruo iframe sends `{ type: "playruo_event", data }`, the
package/dist/index.cjs CHANGED
@@ -48,15 +48,33 @@ var normalizeOperation = (operation) => {
48
48
  if (trimmedOperation === "") {
49
49
  throw new Error("`operation` is required to build a Playruo embed URL.");
50
50
  }
51
+ const operationQuery = trimmedOperation.split("?")[1];
52
+ if (operationQuery != null && new URLSearchParams(operationQuery).has("op")) {
53
+ console.warn(
54
+ "[PlayruoEmbed] `?op=` inside `operation` is deprecated: pass it as the `operationKey` prop."
55
+ );
56
+ }
51
57
  return trimmedOperation.startsWith("/") ? trimmedOperation.slice(1) : trimmedOperation;
52
58
  };
53
- var buildEmbedUrl = ({ operation, storeUrl }) => {
54
- return buildEmbedUrlWithOrigin(PLAYRUO_ORIGIN, { operation, storeUrl });
59
+ var buildEmbedUrl = ({
60
+ operation,
61
+ operationKey,
62
+ storeUrl
63
+ }) => {
64
+ return buildEmbedUrlWithOrigin(PLAYRUO_ORIGIN, {
65
+ operation,
66
+ operationKey,
67
+ storeUrl
68
+ });
55
69
  };
56
- var buildEmbedUrlWithOrigin = (origin, { operation, storeUrl }) => {
70
+ var buildEmbedUrlWithOrigin = (origin, { operation, operationKey, storeUrl }) => {
57
71
  const operationPath = normalizeOperation(operation);
58
72
  const url = new URL(`/${operationPath}`, origin);
59
73
  url.searchParams.set("iframe", "true");
74
+ const trimmedOperationKey = operationKey?.trim();
75
+ if (trimmedOperationKey) {
76
+ url.searchParams.set("op", trimmedOperationKey);
77
+ }
60
78
  if (storeUrl) {
61
79
  url.searchParams.set("storeUrl", storeUrl);
62
80
  }
@@ -68,6 +86,7 @@ var import_jsx_runtime = require("react/jsx-runtime");
68
86
  var PlayruoEmbed = ({
69
87
  baseUrl,
70
88
  operation,
89
+ operationKey,
71
90
  storeUrl,
72
91
  showIframe = false,
73
92
  className,
@@ -77,6 +96,8 @@ var PlayruoEmbed = ({
77
96
  onEvent,
78
97
  onPurchaseRequest,
79
98
  onError,
99
+ onPasswordRequired,
100
+ onPasswordResult,
80
101
  enableWindowedLaunch = false,
81
102
  forwardEventsToParent = true,
82
103
  renderState,
@@ -94,12 +115,24 @@ var PlayruoEmbed = ({
94
115
  const [isValidChromium, setIsValidChromium] = (0, import_react.useState)(true);
95
116
  const [errorMessage, setErrorMessage] = (0, import_react.useState)(null);
96
117
  const [iframeState, setIframeState] = (0, import_react.useState)(null);
118
+ const [passwordRequired, setPasswordRequired] = (0, import_react.useState)(false);
119
+ const [passwordChecked, setPasswordChecked] = (0, import_react.useState)(false);
120
+ const [passwordUnlocked, setPasswordUnlocked] = (0, import_react.useState)(false);
121
+ const [passwordError, setPasswordError] = (0, import_react.useState)(
122
+ void 0
123
+ );
97
124
  const src = (0, import_react.useMemo)(
98
- () => baseUrl != null && baseUrl !== "" ? buildEmbedUrlWithOrigin(baseUrl, { operation, storeUrl }) : buildEmbedUrl({ operation, storeUrl }),
99
- [baseUrl, operation, storeUrl]
125
+ () => baseUrl != null && baseUrl !== "" ? buildEmbedUrlWithOrigin(baseUrl, { operation, operationKey, storeUrl }) : buildEmbedUrl({ operation, operationKey, storeUrl }),
126
+ [baseUrl, operation, operationKey, storeUrl]
100
127
  );
128
+ const embedOrigin = (0, import_react.useMemo)(() => new URL(src).origin, [src]);
129
+ (0, import_react.useEffect)(() => {
130
+ setPasswordRequired(false);
131
+ setPasswordChecked(false);
132
+ setPasswordUnlocked(false);
133
+ setPasswordError(void 0);
134
+ }, [src]);
101
135
  (0, import_react.useEffect)(() => {
102
- if (typeof window === "undefined") return;
103
136
  const detectMobile = () => {
104
137
  const userAgent = navigator.userAgent || navigator.vendor || window.opera;
105
138
  return /android|iPad|iPhone|iPod/i.test(userAgent);
@@ -137,15 +170,14 @@ var PlayruoEmbed = ({
137
170
  tryNow: {
138
171
  ...currentState.tryNow,
139
172
  available: hasIframeConfirmed,
140
- status: currentState.tryNow.status ?? (hasIframeConfirmed ? "Ready" : isCompatible ? "Checking availability\u2026" : "Browser not compatible")
173
+ // A missing status means the state came from the iframe, which only loads on
174
+ // compatible browsers — the incompatible copy is supplied by currentState.
175
+ status: currentState.tryNow.status ?? (hasIframeConfirmed ? "Ready" : "Checking availability\u2026")
141
176
  }
142
177
  }),
143
- [currentState, hasIframeConfirmed, isCompatible]
178
+ [currentState, hasIframeConfirmed]
144
179
  );
145
180
  const getKeyboardApi = (0, import_react.useCallback)(() => {
146
- if (typeof navigator === "undefined") {
147
- return void 0;
148
- }
149
181
  return navigator.keyboard;
150
182
  }, []);
151
183
  const releaseEscapeKeyLock = (0, import_react.useCallback)(() => {
@@ -175,6 +207,23 @@ var PlayruoEmbed = ({
175
207
  },
176
208
  [onDemoCompleted, storeUrl]
177
209
  );
210
+ const postPasswordAction = (0, import_react.useCallback)(
211
+ (message) => {
212
+ const contentWindow = iframeRef.current?.contentWindow;
213
+ if (!contentWindow) {
214
+ return;
215
+ }
216
+ try {
217
+ contentWindow.postMessage(
218
+ { type: "playruo", fromEmbed: true, ...message },
219
+ embedOrigin
220
+ );
221
+ } catch {
222
+ console.error("[PlayruoEmbed] Failed to send the password action");
223
+ }
224
+ },
225
+ [embedOrigin, iframeRef]
226
+ );
178
227
  const postExitFullscreenToIframe = (0, import_react.useCallback)(() => {
179
228
  const iframe = iframeRef.current;
180
229
  if (!iframe) {
@@ -187,22 +236,22 @@ var PlayruoEmbed = ({
187
236
  fromEmbed: true,
188
237
  isFullscreen: false,
189
238
  parentWillHandleFullscreen: true
190
- }, "*");
239
+ }, embedOrigin);
191
240
  } catch {
192
241
  console.error("[PlayruoEmbed] Failed to exit fullscreen");
193
242
  }
194
- }, [iframeRef]);
243
+ }, [embedOrigin, iframeRef]);
195
244
  const postExitFullscreenAckToIframe = (0, import_react.useCallback)(() => {
196
245
  const iframe = iframeRef.current;
197
246
  if (!iframe) {
198
247
  return;
199
248
  }
200
249
  try {
201
- iframe.contentWindow?.postMessage({ type: "ACK_EXIT_FULLSCREEN" }, "*");
250
+ iframe.contentWindow?.postMessage({ type: "ACK_EXIT_FULLSCREEN" }, embedOrigin);
202
251
  } catch {
203
252
  console.error("[PlayruoEmbed] Failed to acknowledge fullscreen exit");
204
253
  }
205
- }, [iframeRef]);
254
+ }, [embedOrigin, iframeRef]);
206
255
  const exitEmbedFullscreen = (0, import_react.useCallback)(
207
256
  async ({
208
257
  reason = "cleanup",
@@ -288,7 +337,7 @@ var PlayruoEmbed = ({
288
337
  }, []);
289
338
  const forwardEventToParent = (0, import_react.useCallback)(
290
339
  (message) => {
291
- if (!forwardEventsToParent || typeof window === "undefined" || window.parent === window) {
340
+ if (!forwardEventsToParent || window.parent === window) {
292
341
  return;
293
342
  }
294
343
  try {
@@ -357,7 +406,7 @@ var PlayruoEmbed = ({
357
406
  isFullscreen: isParentFullscreen,
358
407
  parentWillHandleFullscreen: isParentFullscreen,
359
408
  isReplay: hasPlayedOnce
360
- }, "*");
409
+ }, embedOrigin);
361
410
  console.log("[PlayruoEmbed] Sent tryNowClick to game", hasPlayedOnce ? "(replay)" : "(first time)");
362
411
  iframe.focus();
363
412
  requestAnimationFrame(() => {
@@ -404,6 +453,9 @@ var PlayruoEmbed = ({
404
453
  if (!iframe || event.source !== iframe.contentWindow) {
405
454
  return;
406
455
  }
456
+ if (event.origin !== embedOrigin) {
457
+ return;
458
+ }
407
459
  console.log("\u{1F537} [Embed] Message received from iframe:", { type, data });
408
460
  if (type === "playruo_ready") {
409
461
  console.log("\u{1F537} [Embed] playruo_ready received");
@@ -472,6 +524,24 @@ var PlayruoEmbed = ({
472
524
  const eventPayload = message.payload;
473
525
  onEvent?.(eventPayload);
474
526
  forwardEventToParent({ type: "playruo_event", data: eventPayload });
527
+ } else if (type === "playruo_password_required") {
528
+ console.log("\u{1F537} [Embed] playruo_password_required received");
529
+ setPasswordRequired(true);
530
+ setPasswordChecked(true);
531
+ setPasswordUnlocked(false);
532
+ setPasswordError(void 0);
533
+ onPasswordRequired?.();
534
+ } else if (type === "playruo_password_not_required") {
535
+ console.log("\u{1F537} [Embed] playruo_password_not_required received");
536
+ setPasswordRequired(false);
537
+ setPasswordChecked(true);
538
+ } else if (type === "playruo_password_result" && data) {
539
+ const result = data;
540
+ const success = result.success === true;
541
+ console.log("\u{1F537} [Embed] playruo_password_result received:", result);
542
+ setPasswordUnlocked(success);
543
+ setPasswordError(success ? void 0 : result.error ?? "unknown");
544
+ onPasswordResult?.({ success, error: result.error });
475
545
  }
476
546
  if (type === "error" || type === "closeForBuy" || type === "goToStore") {
477
547
  console.log("\u{1F537} [Embed] Legacy message received:", type);
@@ -487,6 +557,7 @@ var PlayruoEmbed = ({
487
557
  return () => window.removeEventListener("message", handleGameMessage);
488
558
  }, [
489
559
  cleanupEmbedSession,
560
+ embedOrigin,
490
561
  exitEmbedFullscreen,
491
562
  forwardEventToParent,
492
563
  hasPlayedOnce,
@@ -494,6 +565,8 @@ var PlayruoEmbed = ({
494
565
  markStreamActive,
495
566
  onEvent,
496
567
  onError,
568
+ onPasswordRequired,
569
+ onPasswordResult,
497
570
  onPurchaseRequest,
498
571
  onReady,
499
572
  onStateChange,
@@ -516,6 +589,9 @@ var PlayruoEmbed = ({
516
589
  if (!iframe || event.source !== iframe.contentWindow) {
517
590
  return;
518
591
  }
592
+ if (event.origin !== embedOrigin) {
593
+ return;
594
+ }
519
595
  const { type, requestId } = event.data || {};
520
596
  if (type === "playruo_uuid_request") {
521
597
  try {
@@ -530,34 +606,27 @@ var PlayruoEmbed = ({
530
606
  requestId,
531
607
  uuid: storedUUID
532
608
  },
533
- "*"
609
+ embedOrigin
534
610
  );
535
611
  } catch (e) {
536
612
  }
537
- } else if (type === "playruo_uuid_remove") {
538
- try {
539
- localStorage.removeItem("playruo_uuid");
540
- } catch (e) {
541
- }
542
613
  }
543
614
  };
544
615
  window.addEventListener("message", handleUUIDMessage);
545
616
  return () => window.removeEventListener("message", handleUUIDMessage);
546
- }, [iframeRef]);
617
+ }, [embedOrigin, iframeRef]);
547
618
  (0, import_react.useEffect)(() => {
548
619
  const handleFullscreenChange = () => {
549
620
  const iframe = iframeRef.current;
550
621
  const fullscreenElement = getFullscreenElement();
551
622
  const isEmbedFullscreen = Boolean(iframe && fullscreenElement === iframe);
552
- if (isEmbedFullscreen) {
623
+ if (isEmbedFullscreen && iframe) {
553
624
  wasEmbedFullscreenRef.current = true;
554
625
  void lockEscapeKey();
555
- if (iframe) {
556
- setTimeout(() => {
557
- iframe.focus();
558
- iframe.contentWindow?.focus();
559
- }, 200);
560
- }
626
+ setTimeout(() => {
627
+ iframe.focus();
628
+ iframe.contentWindow?.focus();
629
+ }, 200);
561
630
  return;
562
631
  }
563
632
  if (fullscreenElement) {
@@ -602,17 +671,16 @@ var PlayruoEmbed = ({
602
671
  const logCrossOriginStatus = () => {
603
672
  const isIsolated = self.crossOriginIsolated;
604
673
  const hasSharedArrayBuffer = typeof SharedArrayBuffer !== "undefined";
605
- const hasHighResTime = typeof performance !== "undefined" && typeof performance.now === "function";
606
674
  console.group("[PlayruoEmbed] \u{1F512} Cross-Origin Isolation Status");
607
675
  if (isIsolated) {
608
676
  console.log("\u2705 Cross-origin isolation: ENABLED");
609
677
  console.log("\u2705 SharedArrayBuffer support:", hasSharedArrayBuffer ? "Available" : "Not available");
610
- console.log("\u2705 High-resolution timers:", hasHighResTime ? "Available" : "Not available");
678
+ console.log("\u2705 High-resolution timers: Full precision");
611
679
  console.log("\u{1F3AE} Game performance: Optimal");
612
680
  } else {
613
681
  console.warn("\u26A0\uFE0F Cross-origin isolation: DISABLED");
614
682
  console.warn("\u26A0\uFE0F SharedArrayBuffer support:", hasSharedArrayBuffer ? "Available" : "Not available");
615
- console.warn("\u26A0\uFE0F High-resolution timers:", hasHighResTime ? "Available" : "Not available");
683
+ console.warn("\u26A0\uFE0F High-resolution timers: Reduced precision");
616
684
  console.warn("\u{1F3AE} Game performance: May be degraded");
617
685
  console.group("\u{1F4CB} Setup Instructions:");
618
686
  console.log("Add these headers to your server:");
@@ -656,7 +724,7 @@ var PlayruoEmbed = ({
656
724
  console.log("User agent:", navigator.userAgent);
657
725
  console.log("Available APIs:", {
658
726
  SharedArrayBuffer: hasSharedArrayBuffer,
659
- Performance: hasHighResTime,
727
+ Performance: true,
660
728
  WebGL: !!document.createElement("canvas").getContext("webgl"),
661
729
  WebGL2: !!document.createElement("canvas").getContext("webgl2"),
662
730
  WebAssembly: typeof WebAssembly !== "undefined"
@@ -688,22 +756,44 @@ var PlayruoEmbed = ({
688
756
  tryNow: handleTryNow,
689
757
  buyNow: handleBuyNow,
690
758
  canTryNow,
691
- canBuyNow: Boolean(isReady)
759
+ canBuyNow: Boolean(isReady),
760
+ submitPassword: (password2) => {
761
+ if (!password2) {
762
+ return;
763
+ }
764
+ postPasswordAction({ action: "submitPassword", password: password2 });
765
+ },
766
+ requestPassword: () => postPasswordAction({ action: "requestPassword" })
767
+ };
768
+ const password = {
769
+ required: passwordRequired,
770
+ checked: passwordChecked,
771
+ unlocked: passwordUnlocked,
772
+ error: passwordError
692
773
  };
693
774
  if (errorMessage) {
694
- return renderState({ status: "error", message: errorMessage, actions });
775
+ return renderState({ status: "error", message: errorMessage, actions, password });
776
+ }
777
+ if (password.required && !password.unlocked) {
778
+ return renderState({
779
+ status: "loading",
780
+ message: "Password required",
781
+ actions,
782
+ password
783
+ });
695
784
  }
696
785
  if (!isReady) {
697
- return renderState({ status: "loading", message: "Loading\u2026", actions });
786
+ return renderState({ status: "loading", message: "Loading\u2026", actions, password });
698
787
  }
699
788
  if (isCompatible && !iframeState) {
700
- return renderState({ status: "loading", message: "Checking availability\u2026", actions });
789
+ return renderState({ status: "loading", message: "Checking availability\u2026", actions, password });
701
790
  }
702
791
  const message = !isAvailable ? currentState.tryNow.status ?? exposedState.tryNow.status : void 0;
703
792
  return renderState({
704
793
  status: isAvailable ? "available" : "unavailable",
705
794
  message,
706
- actions
795
+ actions,
796
+ password
707
797
  });
708
798
  };
709
799
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className, children: [
@@ -930,20 +1020,11 @@ var EmbedTester = (props = {}) => {
930
1020
  addEvent(message);
931
1021
  }, [addEvent]);
932
1022
  const handleApply = (0, import_react2.useCallback)(() => {
933
- if (!hasPendingConfigChanges) {
934
- return;
935
- }
936
1023
  setAppliedOperation(operationDraft);
937
1024
  setAppliedStoreUrl(storeUrlDraft);
938
1025
  setAppliedEnableWindowedLaunch(enableWindowedLaunchDraft);
939
1026
  resetEmbedSession("Applied updated embed settings");
940
- }, [
941
- enableWindowedLaunchDraft,
942
- hasPendingConfigChanges,
943
- operationDraft,
944
- resetEmbedSession,
945
- storeUrlDraft
946
- ]);
1027
+ }, [enableWindowedLaunchDraft, operationDraft, resetEmbedSession, storeUrlDraft]);
947
1028
  const handleRestartSession = (0, import_react2.useCallback)(() => {
948
1029
  resetEmbedSession("Embed restarted with applied settings");
949
1030
  }, [resetEmbedSession]);
@@ -2047,12 +2128,12 @@ var isPlayruoEmbedMessage = (data) => {
2047
2128
  if (!data || typeof data !== "object") return false;
2048
2129
  const msg = data;
2049
2130
  if (typeof msg.type !== "string") return false;
2050
- return msg.type === "playruo_ready" || msg.type === "playruo_state_update" || msg.type === "playruo_purchase_request" || msg.type === "playruo_demo_completed" || msg.type === "playruo_error" || msg.type === "playruo_session_over" || msg.type === "playruo_stream_start" || msg.type === "playruo_stream_stop" || msg.type === "REQUEST_EXIT_FULLSCREEN" || msg.type === "playruo_event";
2131
+ return msg.type === "playruo_ready" || msg.type === "playruo_state_update" || msg.type === "playruo_purchase_request" || msg.type === "playruo_demo_completed" || msg.type === "playruo_error" || msg.type === "playruo_session_over" || msg.type === "playruo_stream_start" || msg.type === "playruo_stream_stop" || msg.type === "REQUEST_EXIT_FULLSCREEN" || msg.type === "playruo_event" || msg.type === "playruo_password_required" || msg.type === "playruo_password_not_required" || msg.type === "playruo_password_result";
2051
2132
  };
2052
2133
  var isPlayruoInternalMessage = (data) => {
2053
2134
  if (!data || typeof data !== "object") return false;
2054
2135
  const msg = data;
2055
- return msg.type === "playruo" && typeof msg.action === "string" && (msg.action === "tryNowClick" || msg.action === "exitFullscreen" || msg.action === "mute" || msg.action === "unmute") && msg.fromEmbed === true;
2136
+ return msg.type === "playruo" && typeof msg.action === "string" && (msg.action === "tryNowClick" || msg.action === "exitFullscreen" || msg.action === "mute" || msg.action === "unmute" || msg.action === "submitPassword" || msg.action === "requestPassword") && msg.fromEmbed === true;
2056
2137
  };
2057
2138
  // Annotate the CommonJS export names for ESM import in node:
2058
2139
  0 && (module.exports = {