Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -270,6 +270,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 @@ -779,6 +779,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)
{
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,24 @@ 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";

if (!force && !isGlobalPortableExperience)
{
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.
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 +154,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 +167,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 +184,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 Exception($"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 Exception($"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 +243,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 +289,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,14 +11,19 @@ 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using CommunicationData.URLHelpers;
using Cysharp.Threading.Tasks;
using DCL.Diagnostics;
using DCL.Ipfs;
using DCL.WebRequests;
using SceneRuntime.ScenePermissions;
using System;
using System.Collections.Generic;
using System.Threading;

namespace PortableExperiences.Controller
{
/// <summary>
/// Per-session authorization state for local (scene-spawned) Portable Experiences, the counterpart of
/// SmartWearableCache for Smart Wearables.
/// </summary>
public class LocalPortableExperienceCache
{
private readonly IWebRequestController webRequestController;

private readonly Dictionary<string, List<string>> permissionsCache = new (StringComparer.OrdinalIgnoreCase);

public LocalPortableExperienceCache(IWebRequestController webRequestController)
{
this.webRequestController = webRequestController;
}

public HashSet<string> AuthorizedPortableExperiences { get; } = new (StringComparer.OrdinalIgnoreCase);

public HashSet<string> DeniedPortableExperiences { get; } = new (StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Fetches the Portable Experience's scene definitions to compute the permissions that need explicit
/// user authorization; cached for the whole session, so the definitions are fetched only once.
/// </summary>
public async UniTask<IReadOnlyList<string>> GetPermissionsRequiringAuthorizationAsync(string portableExperienceId, IIpfsRealm ipfsRealm, CancellationToken ct)
{
if (permissionsCache.TryGetValue(portableExperienceId, out List<string>? cachedPermissions))
return cachedPermissions;

var permissions = new List<string>();

foreach (string urn in ipfsRealm.SceneUrns)
{
IpfsPath ipfsPath = IpfsHelper.ParseUrn(urn);

SceneEntityDefinition sceneDefinition = await webRequestController
.GetAsync(new CommonArguments(ipfsPath.GetUrl(ipfsRealm.ContentBaseUrl)), ct, ReportCategory.PORTABLE_EXPERIENCE)
.CreateFromJson<SceneEntityDefinition>(WRJsonParser.Newtonsoft);

List<string>? requiredPermissions = sceneDefinition.metadata.requiredPermissions;

if (requiredPermissions == null) continue;

foreach (string permission in requiredPermissions)
if (PermissionRequiresUserAuthorization(permission) && !permissions.Contains(permission))
permissions.Add(permission);
}

permissionsCache[portableExperienceId] = permissions;
return permissions;
}

public void Clear()
{
permissionsCache.Clear();
AuthorizedPortableExperiences.Clear();
DeniedPortableExperiences.Clear();
}

// Mirrors the permission set gated by SmartWearableCache.CacheWearableInternalAsync.
internal static bool PermissionRequiresUserAuthorization(string permission) =>
permission is ScenePermissionNames.USE_WEB3_API
or ScenePermissionNames.OPEN_EXTERNAL_LINK
or ScenePermissionNames.USE_WEBSOCKET
or ScenePermissionNames.SPAWN_PORTABLE_EXPERIENCE
or ScenePermissionNames.USE_FETCH;
}
}

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

Loading