Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ MonoBehaviour:
m_DefaultGroup: 47f803e1e9c5079449bd106df98a0b7d
m_currentHash:
serializedVersion: 2
Hash: 00000000000000000000000000000000
Hash: f55c7e45ff2b8ab7c1b6df8ae83134d6
m_OptimizeCatalogSize: 0
m_BuildRemoteCatalog: 0
m_CatalogRequestsTimeout: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 696c60f7fb34b41808f12ead4e67ea6c
m_Address: Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/AuthorizationPopup/Assets/PortableExperienceAuthorizationPopup.prefab
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 6a252383173104e17acac54eb364afc4
m_Address: Assets/DCL/AvatarRendering/AvatarShape/Assets/PointAtMarker.prefab
m_ReadOnly: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public class SmartWearableAuthorizationPopupView : ViewBase, IView
[field: SerializeField]
public GameObject FetchAPIPermissionContent { get; private set; }

[field: SerializeField]
public GameObject SpawnPortableExperiencePermissionContent { get; private set; }

public async UniTask WaitChoiceAsync()
{
await UniTask.WhenAny(AuthorizeButton.OnClickAsync(), DenyButton.OnClickAsync());
Expand All @@ -68,6 +71,7 @@ public void SetPermissions(List<string> permissions)
OpenExternalUrlPermissionContent.SetActive(permissions.Contains(ScenePermissionNames.OPEN_EXTERNAL_LINK));
WebSocketPermissionContent.SetActive(permissions.Contains(ScenePermissionNames.USE_WEBSOCKET));
FetchAPIPermissionContent.SetActive(permissions.Contains(ScenePermissionNames.USE_FETCH));
SpawnPortableExperiencePermissionContent.SetActive(permissions.Contains(ScenePermissionNames.SPAWN_PORTABLE_EXPERIENCE));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using Cysharp.Threading.Tasks;
using DCL.Diagnostics;
using DCL.FeatureFlags;
using DCL.Utilities.Extensions;
using PortableExperiences.Controller;
using System;
using System.Threading;
Expand Down Expand Up @@ -52,14 +51,19 @@ public async UniTask<string> ExecuteCommandAsync(string[] parameters, Cancellati

await UniTask.SwitchToMainThread(ct);

var result = await portableExperiencesController.CreatePortableExperienceByEnsAsync(new ENS(pxName), ct, true, true).SuppressAnyExceptionWithFallback(new IPortableExperiencesController.SpawnResponse(), ReportCategory.PORTABLE_EXPERIENCE);
try
{
await portableExperiencesController.CreatePortableExperienceByEnsAsync(new ENS(pxName), ct, isGlobalPortableExperience: true, force: true, requireUserAuthorization: true);

bool isSuccess = !string.IsNullOrEmpty(result.ens);

if (ct.IsCancellationRequested)
return "🔴 Error. The operation was canceled!";

return isSuccess ? $"🟢 The Portable Experience {pxName} has started loading" : $"🔴 Error. Could not load {pxName} as a Portable Experience";
return $"🟢 The Portable Experience {pxName} has started loading";
}
catch (OperationCanceledException) { return "🔴 Error. The operation was canceled!"; }
catch (PortableExperienceAuthorizationDeniedException) { return $"🔴 {pxName} was not loaded because you denied its authorization request"; }
catch (Exception e)
{
ReportHub.LogException(e, ReportCategory.PORTABLE_EXPERIENCE);
return $"🔴 Error. Could not load {pxName} as a Portable Experience";
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ Entity playerEntity

dynamicWorldContainer.RealmController.GlobalWorld = globalWorld;
staticContainer.PortableExperiencesController.GlobalWorld = globalWorld;
staticContainer.PortableExperiencesController.AuthorizationHandler = new PortableExperienceAuthorizationPopupHandler(dynamicWorldContainer.MvcManager);

InitializeDebugPanel(staticContainer.DebugContainerBuilder, debugUiRoot);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,7 @@ await MapRendererContainer
uiShellContainer.MvcManager,
wearableContainer.ThumbnailProvider,
identityCache),
new PortableExperienceAuthorizationPopupPlugin(assetsProvisioner, uiShellContainer.MvcManager),
new AvatarLocomotionOverridesGlobalPlugin(),
new JumpIndicatorPlugin(assetsProvisioner),
new SpringBonesPlugin(springBoneSimulationSettings),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,24 @@
using SceneRunner.Scene;
using System.Linq;
using DCL.Multiplayer.Connections.DecentralandUrls;
using ECS.SceneLifeCycle.Realm;
using DCL.Utility;
using SceneRuntime.ScenePermissions;

namespace PortableExperiences.Controller
{
public class ECSPortableExperiencesController : IPortableExperiencesController
{
private readonly IWeb3IdentityCache web3IdentityCache;
private const int MAX_PORTABLE_EXPERIENCES_PER_SCENE = 10;

private readonly IWebRequestController webRequestController;
private readonly IScenesCache scenesCache;
private readonly LocalPortableExperienceCache localPortableExperienceCache;
private readonly List<IPortableExperiencesController.SpawnResponse> spawnResponsesList = new ();
private readonly HashSet<string> loadingPortableExperiences = new ();
private readonly Dictionary<string, int> localPortableExperiencesPerScene = new ();
private readonly ILaunchMode launchMode;
private readonly IDecentralandUrlsSource urlsSources;
private GlobalWorld globalWorld;
private GlobalWorld? globalWorld;

public Dictionary<string, Entity> PortableExperienceEntities { get; } = new ();

Expand All @@ -44,27 +47,35 @@ public GlobalWorld GlobalWorld
set => globalWorld = value;
}

private World world => globalWorld.EcsWorld;
public IPortableExperienceAuthorizationHandler? AuthorizationHandler { get; set; }

private World world => GlobalWorld.EcsWorld;

public event Action<string> PortableExperienceLoaded;
public event Action<string> PortableExperienceUnloaded;
public event Action<string>? PortableExperienceLoaded;
public event Action<string>? PortableExperienceUnloaded;

public ECSPortableExperiencesController(
IWeb3IdentityCache web3IdentityCache,
IWebRequestController webRequestController,
IScenesCache scenesCache,
LocalPortableExperienceCache localPortableExperienceCache,
ILaunchMode launchMode,
IDecentralandUrlsSource urlsSources)
{
this.web3IdentityCache = web3IdentityCache;
this.webRequestController = webRequestController;
this.scenesCache = scenesCache;
this.localPortableExperienceCache = localPortableExperienceCache;
this.launchMode = launchMode;
this.urlsSources = urlsSources;

// The controller lives for the whole application lifetime, so the subscription is never torn down.
web3IdentityCache.OnIdentityCleared += localPortableExperienceCache.Clear;
}

public async UniTask<IPortableExperiencesController.SpawnResponse> CreatePortableExperienceByEnsAsync(ENS ens, CancellationToken ct, bool isGlobalPortableExperience = false, bool force = false)
public async UniTask<IPortableExperiencesController.SpawnResponse> CreatePortableExperienceByEnsAsync(ENS ens, CancellationToken ct, bool isGlobalPortableExperience = false, bool force = false, bool requireUserAuthorization = false)
{
ISceneFacade? parentScene = scenesCache.Scenes.FirstOrDefault(s => s.SceneStateProvider.IsCurrent);

if (!force)
switch (isGlobalPortableExperience)
{
Expand All @@ -75,6 +86,10 @@ public ECSPortableExperiencesController(
//If it IS a Global PX but Global PXs are disabled
case true when !FeatureFlagsConfiguration.Instance.IsEnabled(FeatureFlagsStrings.GLOBAL_PORTABLE_EXPERIENCE):
throw new Exception("Global Portable Experiences are disabled");

//If it's a local PX (not Global) but the requesting scene does not have permissions to spawn PXs
case false when parentScene != null && !parentScene.SceneData.SceneEntityDefinition.metadata.requiredPermissions.Contains(ScenePermissionNames.SPAWN_PORTABLE_EXPERIENCE):
throw new Exception($"The parent scene {parentScene.Info.Name} is trying to spawn a portable experience but lacks the '{ScenePermissionNames.SPAWN_PORTABLE_EXPERIENCE}' permission.");
}

var portableExperienceId = ens.ToString();
Expand Down Expand Up @@ -112,11 +127,28 @@ public ECSPortableExperiencesController(
//The loaded realm does not have any fixed scene, so it cannot be loaded as a Portable Experience
throw new Exception($"Scene not Available in provided Portable Experience with ens: {ens}");

var ipfsRealm = new IpfsRealm(portableExperiencePath, result);
string parentSceneName = parentScene?.Info.Name ?? "main";

bool isSceneSpawned = !force && !isGlobalPortableExperience;

if (isSceneSpawned || requireUserAuthorization)
{
if (isSceneSpawned)
EnsureSceneSpawnCapacity(parentSceneName);

string portableExperienceName = string.IsNullOrEmpty(result.configurations.realmName) ? portableExperienceId : result.configurations.realmName;
await EnsureAuthorizedByUserAsync(portableExperienceId, portableExperienceName, ipfsRealm, ct);

// Re-checked: concurrent spawns may have consumed the remaining capacity while awaiting.
if (isSceneSpawned)
EnsureSceneSpawnCapacity(parentSceneName);
}

var realmData = new RealmData();

realmData.Reconfigure(
new IpfsRealm(portableExperiencePath,
result),
ipfsRealm,
result.configurations.realmName.EnsureNotNull("Realm name not found"),
result.configurations.networkId,
result.comms?.adapter ?? string.Empty,
Expand All @@ -126,8 +158,6 @@ public ECSPortableExperiencesController(
WorldManifest.Empty
);

ISceneFacade parentScene = scenesCache.Scenes.FirstOrDefault(s => s.SceneStateProvider.IsCurrent);
string parentSceneName = parentScene != null ? parentScene.Info.Name : "main";
Entity portableExperienceEntity = world.Create();
world.Add(portableExperienceEntity, new PortableExperienceRealmComponent(realmData, parentSceneName, isGlobalPortableExperience), new PortableExperienceComponent(ens));
world.Add(portableExperienceEntity, new PortableExperienceMetadata
Expand All @@ -141,6 +171,12 @@ public ECSPortableExperiencesController(

PortableExperienceEntities.Add(portableExperienceId, portableExperienceEntity);

if (!isGlobalPortableExperience)
{
localPortableExperiencesPerScene.TryGetValue(parentSceneName, out int count);
localPortableExperiencesPerScene[parentSceneName] = count + 1;
}

PortableExperienceLoaded?.Invoke(portableExperienceId);

return new IPortableExperiencesController.SpawnResponse
Expand All @@ -152,6 +188,50 @@ public ECSPortableExperiencesController(
}
}

private async UniTask EnsureAuthorizedByUserAsync(string portableExperienceId, string portableExperienceName, IIpfsRealm ipfsRealm, CancellationToken ct)
{
if (localPortableExperienceCache.AuthorizedPortableExperiences.Contains(portableExperienceId)) return;

if (localPortableExperienceCache.DeniedPortableExperiences.Contains(portableExperienceId))
throw new PortableExperienceAuthorizationDeniedException($"The user has denied authorization for the portable experience '{portableExperienceId}' in this session.");

IReadOnlyList<string> permissions = await localPortableExperienceCache.GetPermissionsRequiringAuthorizationAsync(portableExperienceId, ipfsRealm, ct);

if (permissions.Count == 0)
{
localPortableExperienceCache.AuthorizedPortableExperiences.Add(portableExperienceId);
return;
}

IPortableExperienceAuthorizationHandler? authorizationHandler = AuthorizationHandler;

// Fail closed: a portable experience that requires permissions must never spawn without explicit consent.
if (authorizationHandler == null)
{
ReportHub.LogError(ReportCategory.PORTABLE_EXPERIENCE, $"Cannot request authorization for portable experience '{portableExperienceId}': UI is not initialized yet.");
throw new Exception($"Portable experience '{portableExperienceId}' requires user authorization but the UI is not available yet.");
}

bool authorized = await authorizationHandler.RequestAuthorizationAsync(portableExperienceName, permissions, ct);

// A cancelled request must not be recorded as a user decision.
ct.ThrowIfCancellationRequested();

if (!authorized)
{
localPortableExperienceCache.DeniedPortableExperiences.Add(portableExperienceId);
throw new PortableExperienceAuthorizationDeniedException($"The user denied the portable experience '{portableExperienceName}'.");
}

localPortableExperienceCache.AuthorizedPortableExperiences.Add(portableExperienceId);
}

private void EnsureSceneSpawnCapacity(string parentSceneName)
{
if (localPortableExperiencesPerScene.TryGetValue(parentSceneName, out int count) && count >= MAX_PORTABLE_EXPERIENCES_PER_SCENE)
throw new Exception($"The scene '{parentSceneName}' has reached the maximum number of portable experiences it can spawn ({MAX_PORTABLE_EXPERIENCES_PER_SCENE}).");
}

public bool CanKillPortableExperience(string id)
{
if (!PortableExperienceEntities.TryGetValue(id, out Entity portableExperienceEntity)) return false;
Expand All @@ -167,7 +247,7 @@ public bool CanKillPortableExperience(string id)
case PortableExperienceType.Local:
if (!FeatureFlagsConfiguration.Instance.IsEnabled(FeatureFlagsStrings.PORTABLE_EXPERIENCE)) return false;

ISceneFacade currentSceneFacade = scenesCache.CurrentScene.Value;
ISceneFacade? currentSceneFacade = scenesCache.CurrentScene.Value;
return currentSceneFacade != null && metadata.ParentSceneId == currentSceneFacade.Info.Name;

case PortableExperienceType.SmartWearable:
Expand Down Expand Up @@ -213,6 +293,15 @@ public IPortableExperiencesController.ExitResponse UnloadPortableExperienceById(
{
if (PortableExperienceEntities.TryGetValue(id, out Entity portableExperienceEntity))
{
PortableExperienceMetadata metadata = world.Get<PortableExperienceMetadata>(portableExperienceEntity);

if (metadata.Type == PortableExperienceType.Local &&
localPortableExperiencesPerScene.TryGetValue(metadata.ParentSceneId, out int count))
{
if (count <= 1) localPortableExperiencesPerScene.Remove(metadata.ParentSceneId);
else localPortableExperiencesPerScene[metadata.ParentSceneId] = count - 1;
}

world.Add<DeleteEntityIntention>(portableExperienceEntity);

PortableExperienceEntities.Remove(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Cysharp.Threading.Tasks;
using System.Collections.Generic;
using System.Threading;

namespace PortableExperiences.Controller
{
/// <summary>
/// Decides whether a scene-spawned Portable Experience may run with the permissions it requests.
/// Implemented outside this assembly so the scene life-cycle does not depend on UI.
/// </summary>
public interface IPortableExperienceAuthorizationHandler
{
UniTask<bool> RequestAuthorizationAsync(string portableExperienceName, IReadOnlyList<string> permissions, CancellationToken ct);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,23 @@ namespace PortableExperiences.Controller
{
public interface IPortableExperiencesController
{
event Action<string> PortableExperienceLoaded;
event Action<string>? PortableExperienceLoaded;

event Action<string> PortableExperienceUnloaded;
event Action<string>? PortableExperienceUnloaded;

Dictionary<string, Entity> PortableExperienceEntities { get; }

GlobalWorld GlobalWorld { get; set; }

/// <summary>
/// Assigned from the composition root once the UI shell exists.
/// </summary>
IPortableExperienceAuthorizationHandler? AuthorizationHandler { get; set; }

bool CanKillPortableExperience(string id);

UniTask<SpawnResponse> CreatePortableExperienceByEnsAsync(ENS ens, CancellationToken ct, bool isGlobalPortableExperience = false, bool force = false);
/// <param name="requireUserAuthorization">Gates the spawn behind user consent even when it is global or forced; scene-spawned local Portable Experiences are always gated.</param>
UniTask<SpawnResponse> CreatePortableExperienceByEnsAsync(ENS ens, CancellationToken ct, bool isGlobalPortableExperience = false, bool force = false, bool requireUserAuthorization = false);

ExitResponse UnloadPortableExperienceById(string id);

Expand Down
Loading