diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitCesiumMapProvider.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitCesiumMapProvider.cs index 385554f8673..d07d8ab8b9d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitCesiumMapProvider.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitCesiumMapProvider.cs @@ -55,11 +55,40 @@ public sealed class BitCesiumMapProvider : BitMapProviderBase /// public override string JsObjectName => "BitMapCesium"; + /// The CesiumJS release this provider is written against. + public const string CesiumVersion = "1.124"; + + /// Default : the official CesiumJS CDN build. + public const string DefaultBaseUrl = $"https://cesium.com/downloads/cesiumjs/releases/{CesiumVersion}/Build/Cesium"; + + /// + /// Base URL of the CesiumJS build to load (the Build/Cesium folder), without a trailing + /// slash. Defaults to . + /// + /// Point this at a copy on your own origin when the page is served cross-origin isolated with + /// Cross-Origin-Embedder-Policy: require-corp: cesium.com sends neither a + /// Cross-Origin-Resource-Policy nor an Access-Control-Allow-Origin header, so the + /// browser blocks the script in both no-cors and CORS mode. Note that CesiumJS resolves its own + /// workers, .wasm decoders and assets relative to the URL it was loaded from, so the whole + /// Build/Cesium folder has to be reachable under this base - pointing it at a lone copy of + /// Cesium.js is not enough. + /// + /// + public string BaseUrl { get; set; } = DefaultBaseUrl; + /// - public override IReadOnlyList Scripts => ["https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Cesium.js"]; + public override IReadOnlyList Scripts => [$"{NormalizedBaseUrl}/Cesium.js"]; /// - public override IReadOnlyList Stylesheets => ["https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Widgets/widgets.css"]; + public override IReadOnlyList Stylesheets => [$"{NormalizedBaseUrl}/Widgets/widgets.css"]; + + /// + /// without a trailing slash, falling back to + /// when it is blank, so the composed URLs never end up with an empty or doubled separator. + /// + private string NormalizedBaseUrl => string.IsNullOrWhiteSpace(BaseUrl) + ? DefaultBaseUrl + : BaseUrl.Trim().TrimEnd('/'); /// public override object BuildOptionsPayload() diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapLeaflet.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapLeaflet.ts index 9ccefaa775a..0b5738e4ae2 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapLeaflet.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapLeaflet.ts @@ -40,6 +40,40 @@ namespace BitBlazorUI { export class BitMapLeaflet { private static _maps: { [id: string]: LeafletState } = {}; + /** + * Wires the no-cors then CORS retry of BitMapHelpers onto a tile layer: when every tile + * of a cross-origin layer fails on a cross-origin isolated page (COEP: require-corp + * blocks no-cors subresources that carry no CORP header), the layer is redrawn in CORS + * mode. Leaflet reads options.crossOrigin per tile, so redraw() is enough. + */ + private static _wireTileCorsFallback(layer: any, urlTemplate: string, isVerification: boolean = false) { + const fallback = BitMapHelpers.createTileCorsFallback(urlTemplate, () => { + layer.options.crossOrigin = 'anonymous'; + // Re-wire so the CORS-mode attempt is watched too: if its whole batch fails as + // well, the helper retracts the origin-wide mark instead of leaving the host stuck + // in CORS mode for every later layer. The listeners above stay attached but are + // inert - a fallback acts at most once - and at most one extra pair is ever added. + // Wired before the redraw, which re-requests the tiles synchronously and would + // otherwise fire their load-start events past a listener that is not there yet. + BitMapLeaflet._wireTileCorsFallback(layer, urlTemplate, true); + layer.redraw(); + return true; + }, isVerification, () => { + // The verification failed as fully as the first attempt, so CORS mode was not the + // answer for this host and the mark has just been retracted. This layer is still + // the one that was switched, though, and would go on asking for tiles in a mode + // this host refuses - i.e. render nothing - so put it back the way it was created. + // No new fallback is wired to it: it is back in the state whose failure started + // this, and re-arming it as a marker would loop. + layer.options.crossOrigin = undefined; + // The map may have been disposed while the decision was deferred. + try { layer.redraw(); } catch { /* ignore */ } + }); + layer.on('tileloadstart', fallback.onTileStart); + layer.on('tileload', fallback.onTileLoad); + layer.on('tileerror', fallback.onTileError); + } + public static async init(id: string, canvasId: string, element: HTMLElement, dotnetObj: DotNetObject | null | undefined, options: any) { element = await BitMapHelpers.resolveMapCanvas(canvasId, element); @@ -84,10 +118,15 @@ namespace BitBlazorUI { tileOpacity: o.tileOpacity ?? 1, }; const baseTileLayer = L.tileLayer(tileOptions.tileUrl, { + crossOrigin: BitMapHelpers.tileCrossOrigin(tileOptions.tileUrl), maxZoom: tileOptions.tileMaxZoom, attribution: tileOptions.tileAttribution, opacity: tileOptions.tileOpacity, - }).addTo(map); + }); + // Wired before the layer is added to the map: adding it requests the first batch of + // tiles synchronously, and it is exactly that batch the fallback has to count. + BitMapLeaflet._wireTileCorsFallback(baseTileLayer, tileOptions.tileUrl); + baseTileLayer.addTo(map); const state: LeafletState = { L, map, dotnetObj, @@ -163,10 +202,13 @@ namespace BitBlazorUI { if (tileChanged) { if (s.baseTileLayer) s.map.removeLayer(s.baseTileLayer); s.baseTileLayer = L.tileLayer(next.tileUrl, { + crossOrigin: BitMapHelpers.tileCrossOrigin(next.tileUrl), maxZoom: next.tileMaxZoom, attribution: next.tileAttribution, opacity: next.tileOpacity, - }).addTo(s.map); + }); + BitMapLeaflet._wireTileCorsFallback(s.baseTileLayer, next.tileUrl); + s.baseTileLayer.addTo(s.map); s._tileOptions = next; } if (s.baseTileLayer) { @@ -427,11 +469,13 @@ namespace BitBlazorUI { delete s.tileOverlays[opts.id]; } const tl = L.tileLayer(opts.urlTemplate, { + crossOrigin: BitMapHelpers.tileCrossOrigin(opts.urlTemplate), opacity: opts.opacity ?? 1, zIndex: opts.zIndex ?? 100, maxZoom: opts.maxZoom ?? 19, attribution: opts.attribution || "", }); + BitMapLeaflet._wireTileCorsFallback(tl, opts.urlTemplate); tl.addTo(s.map); s.tileOverlays[opts.id] = tl; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapOpenLayers.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapOpenLayers.ts index 01564f73bfa..51cac0573da 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapOpenLayers.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapOpenLayers.ts @@ -24,6 +24,66 @@ namespace BitBlazorUI { private static readonly _defaultTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; private static readonly _osmAttribution = '© OpenStreetMap contributors'; + /** + * Builds the XYZ source of a tile layer and assigns it, wiring the no-cors then CORS + * retry of BitMapHelpers: when every tile of a cross-origin layer fails on a cross-origin + * isolated page (COEP: require-corp blocks no-cors subresources that carry no CORP + * header), the source is rebuilt in CORS mode. OpenLayers reads crossOrigin when the + * source is constructed, so the retry has to create a new one - and because the rebuild + * goes through here again with isVerification set, the CORS-mode source gets a fallback + * that no longer retries but verifies: if that attempt fails as fully, it retracts the + * origin-wide mark and rebuilds the layer's source in plain no-cors mode, since the source + * that produced the evidence would otherwise keep asking for tiles in a mode this host + * refuses and render nothing for the rest of the session. + */ + private static _setTileSource(ol: any, layer: any, params: { url: string, maxZoom: number, attributions: string }, isVerification: boolean = false) { + const source = new ol.XYZ({ + crossOrigin: BitMapHelpers.tileCrossOrigin(params.url), + url: params.url, + maxZoom: params.maxZoom, + attributions: params.attributions, + }); + const fallback = BitMapHelpers.createTileCorsFallback(params.url, () => { + // The retry is deferred, so a sync() may have swapped a different source onto this + // layer meanwhile. Rebuilding from the captured params would resurrect the tile url + // that sync() just replaced - and because the state already records the new url, no + // later sync() would consider it changed and put it back. Only retry while the + // source this fallback was wired to is still the one the layer is showing; + // declining here also rolls the origin-wide mark back, since the CORS-mode layer + // that would have put it to the test is never created. + if (layer.getSource() !== source) return false; + BitMapOpenLayers._setTileSource(ol, layer, params, true); + return true; + }, isVerification, () => { + // Same guard as the retry: a sync() may have swapped a different source onto this + // layer while the decision was deferred, and reverting would resurrect the tile + // url that sync() replaced. + if (layer.getSource() !== source) return; + BitMapOpenLayers._setPlainTileSource(ol, layer, params); + }); + source.on('tileloadstart', fallback.onTileStart); + source.on('tileloadend', fallback.onTileLoad); + source.on('tileloaderror', fallback.onTileError); + layer.setSource(source); + } + + /** + * Rebuilds a tile source in plain no-cors mode, wiring no fallback to it: this is what a + * failed verification reverts to, and the state it reverts to is the very one whose failure + * started the retry - watching it again would mark the origin, redraw in CORS mode, fail + * the verification and revert once more, round and round. crossOrigin is spelled out rather + * than read from tileCrossOrigin() so a concurrent layer re-marking the origin in between + * cannot turn the revert back into the mode being reverted from. + */ + private static _setPlainTileSource(ol: any, layer: any, params: { url: string, maxZoom: number, attributions: string }) { + layer.setSource(new ol.XYZ({ + crossOrigin: undefined, + url: params.url, + maxZoom: params.maxZoom, + attributions: params.attributions, + })); + } + private static _resolveTileUrl(o: any): string { return (o.tileUrl || BitMapOpenLayers._defaultTileUrl).replace('{s}', 'a'); } @@ -51,13 +111,11 @@ namespace BitBlazorUI { const tileAttribution = BitMapOpenLayers._resolveTileAttribution(tileUrl, o.tileAttribution); const tileOpacity = o.tileOpacity ?? 1; - const baseTile = new ol.TileLayer({ - source: new ol.XYZ({ - url: tileUrl, - maxZoom: tileMaxZoom, - attributions: tileAttribution, - }), - opacity: tileOpacity, + const baseTile = new ol.TileLayer({ opacity: tileOpacity }); + BitMapOpenLayers._setTileSource(ol, baseTile, { + url: tileUrl, + maxZoom: tileMaxZoom, + attributions: tileAttribution, }); const map = new ol.Map({ @@ -209,11 +267,11 @@ namespace BitBlazorUI { if (nextTileUrl !== s.tileUrl || nextTileMaxZoom !== s.tileMaxZoom || nextTileAttribution !== s.tileAttribution) { - s.baseTileLayer.setSource(new ol.XYZ({ + BitMapOpenLayers._setTileSource(ol, s.baseTileLayer, { url: nextTileUrl, maxZoom: nextTileMaxZoom, attributions: nextTileAttribution, - })); + }); s.tileUrl = nextTileUrl; s.tileMaxZoom = nextTileMaxZoom; s.tileAttribution = nextTileAttribution; @@ -460,14 +518,14 @@ namespace BitBlazorUI { delete s.tileOverlays[opts.id]; } const tl = new ol.TileLayer({ - source: new ol.XYZ({ - url: (opts.urlTemplate || '').replace('{s}', 'a'), - maxZoom: opts.maxZoom ?? 19, - attributions: opts.attribution || '', - }), opacity: opts.opacity ?? 1, zIndex: opts.zIndex ?? 100, }); + BitMapOpenLayers._setTileSource(ol, tl, { + url: (opts.urlTemplate || '').replace('{s}', 'a'), + maxZoom: opts.maxZoom ?? 19, + attributions: opts.attribution || '', + }); s.tileOverlays[opts.id] = tl; s.map.addLayer(tl); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapShared.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapShared.ts index ee5b2ce38f8..0feb55d96d3 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapShared.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Map/Providers/BitMapShared.ts @@ -5,6 +5,204 @@ namespace BitBlazorUI { /** Helpers shared by every BitMap provider implementation. */ export class BitMapHelpers { + /** + * Origins whose tiles failed in no-cors mode and are therefore requested in CORS mode from + * now on. Keyed per origin, not a single page-wide flag: one tile host switching to CORS + * says nothing about another, and a layer pointed at a host that sends CORP but no + * Access-Control-Allow-Origin would be broken - not fixed - by being dragged along. An + * entry is retracted again when the CORS-mode attempt fails just as completely, since the + * evidence that wrote it (every tile of one layer failing) is also what a layer that 404s + * or 401s its whole batch looks like - see createTileCorsFallback. + */ + private static _corsTileOrigins: { [origin: string]: true } = {}; + + /** + * How long a layer is given, after its first tile error, to prove itself reachable before + * the origin is switched to CORS mode - and how often the decision is re-examined while + * tiles are still on the wire. Long enough for the rest of the initial batch of tiles to + * land, short enough that a genuinely blocked layer redraws without a visible wait. + */ + private static readonly _tileCorsRetryDelay = 1_000; + + /** + * Upper bound on how long the decision may be deferred by tiles that are still loading. + * A request that never settles - a stalled connection, a tile server that accepts and then + * holds - must not leave a timer re-arming itself for the life of the page; past this the + * layer's fallback simply stops watching and decides nothing, which is the safe direction: + * an origin is only ever marked on positive evidence. + */ + private static readonly _tileCorsMaxWait = 15_000; + + /** Origin of a tile url template, or null when it does not parse. */ + private static tileOrigin(urlTemplate: string): string | null { + try { + // {s} is Leaflet's subdomain placeholder and can sit in the host, so it has to be + // substituted before the URL parses; every other placeholder is in the path. + const url = (urlTemplate || '').replace('{s}', 'a'); + return new URL(url, document.baseURI).origin; + } catch { + return null; + } + } + + /** + * crossOrigin value to create a tile layer with, or undefined for the default no-cors mode. + * + * Tiles start out in no-cors mode so tile servers that send neither CORS nor CORP headers + * keep working (requesting those in CORS mode would block them). When the page is + * cross-origin isolated with Cross-Origin-Embedder-Policy: require-corp - the only COEP + * value WebKit understands, and what the multi-threaded WebAssembly runtime needs there - + * a cross-origin tile without a Cross-Origin-Resource-Policy header is blocked in no-cors + * mode instead, so the first layer on that host whose whole initial batch of tiles fails + * marks the host and redraws itself in CORS mode (OSM, Carto, OpenTopoMap... all send + * Access-Control-Allow-Origin: *). Mirrors the no-cors then CORS retry the Extras/Legacy + * script and stylesheet loaders do. + */ + static tileCrossOrigin(urlTemplate: string): string | undefined { + const origin = BitMapHelpers.tileOrigin(urlTemplate); + return origin !== null && BitMapHelpers._corsTileOrigins[origin] ? 'anonymous' : undefined; + } + + /** + * Creates the tile-load listeners implementing the no-cors then CORS retry for a single + * tile layer. `retry` is invoked at most once, and only when every tile of that layer + * failed: a layer that loaded at least one tile is talking to a reachable server, so a + * later error is an ordinary missing/failing tile and must not switch that host to CORS + * mode (which would break a tile server that sends no CORS headers). The decision is + * therefore deferred by `_tileCorsRetryDelay` from the first error, giving the rest of + * the initial batch of tiles - which a layer requests in parallel, so their results + * interleave - the chance to disprove it. + * + * `retry` returns false when it declined to redraw - the layer it was wired to has moved + * on meanwhile - in which case the mark is rolled back: it is the redrawn layer that + * verifies the mark, so a mark nothing redrew is a mark nothing would ever disprove. + * + * `isVerification` marks the fallback of that redrawn, CORS-mode layer, and is passed in + * by the provider rather than inferred from the origin already being marked. That layer + * does not retry, it verifies: "every tile of this layer failed" is a heuristic - a layer + * whose whole batch 404s (wrong path template) or 401s (unauthenticated tileset) looks + * exactly like a COEP block from here - so if the CORS-mode attempt fails just as fully, + * CORS mode is not what this host needed and the origin-wide mark is retracted and + * `revert` is called to put that layer back into no-cors mode: retracting the mark alone + * would leave the layer that produced the evidence requesting tiles in a mode this host + * refuses, i.e. rendering nothing for the rest of the session. Every other layer on a + * marked origin merely consumes the mark and watches nothing: were it to verify as well, + * an unrelated overlay 404ing its own batch would retract a mark another layer had just + * proved right. This cannot ping-pong: retracting performs no retry, the reverted layer is + * wired no new fallback, and each fallback acts at most once. + * + * `onTileStart` has to be wired to the provider's tile-load-start event for the "every + * tile failed" test to mean what it says: without it the verdict is a plain wall clock, + * and on a slow link one ordinary 404 whose siblings are merely slow would read as a whole + * failed batch. Counting what is still in flight lets the deadline be a floor rather than + * a verdict - the decision waits for the batch to settle - so that one loaded tile always + * gets the chance to disprove it. + */ + static createTileCorsFallback( + urlTemplate: string, + retry: () => boolean | void, + isVerification: boolean = false, + revert?: () => void) { + const origin = BitMapHelpers.tileOrigin(urlTemplate); + // Compared against true explicitly: crossOriginIsolated is undefined on browsers that + // predate it (Safari < 15.2, Chrome < 87, Firefox < 79), and `x && undefined` yields + // undefined - which a `=== false` guard would wave through, flipping those browsers to + // CORS mode on the first ordinary tile error even though nothing there blocks no-cors + // tiles in the first place. lib.dom types it as boolean, so only the runtime knows. + const eligible = origin !== null + && origin !== location.origin + && self.crossOriginIsolated === true; + const verifying = eligible && isVerification; + const marking = eligible && !isVerification && BitMapHelpers._corsTileOrigins[origin!] !== true; + if (!verifying && !marking) { + // Neither role applies, so this layer's tile errors decide nothing about the origin. + const noop = () => { }; + return { onTileStart: noop, onTileLoad: noop, onTileError: noop }; + } + let loaded = false; + let acted = false; + let scheduled = false; + let inFlight = 0; + let waited = 0; + + const settle = () => { + if (loaded || acted) return; + if (inFlight > 0) { + // Siblings are still on the wire, so "every tile of this layer failed" is not + // established yet - only "every tile that has come back so far did". A tile + // still loading can be the one that disproves it, and on a slow link it + // routinely is, so the delay acts as a floor and the batch settling is what + // actually triggers the decision. Bounded, so a request that never settles + // cannot keep this timer re-arming for the life of the page. + waited += BitMapHelpers._tileCorsRetryDelay; + if (waited >= BitMapHelpers._tileCorsMaxWait) { + acted = true; + return; + } + setTimeout(settle, BitMapHelpers._tileCorsRetryDelay); + return; + } + acted = true; + if (verifying) { + // CORS mode did not help this layer either - retract the origin-wide + // decision instead of leaving the host poisoned for everyone else. + // Guarded on the mark still standing so a concurrent layer that just + // re-established it is not undone. + if (BitMapHelpers._corsTileOrigins[origin!] === true) { + delete BitMapHelpers._corsTileOrigins[origin!]; + } + // Unconditionally, even when the mark was already gone: this layer is in CORS + // mode either way, and a host that refuses CORS renders nothing until it is + // put back the way it was created. The provider wires no fallback to the + // reverted layer, so this ends here. + if (revert) { + try { revert(); } catch { /* ignore */ } + } + return; + } + // Whether the mark is ours to retract below. Two layers of the same host + // can both be markers - they are created before either has acted - so the + // one that acts second must not undo the first one's verified mark. + const wasMarked = BitMapHelpers._corsTileOrigins[origin!] === true; + // Written before the retry, not after: the retry rebuilds the layer, and + // what it rebuilds with is read back out of this very map by + // tileCrossOrigin(). + BitMapHelpers._corsTileOrigins[origin!] = true; + // No longer inside the tile event, so the map may have been disposed + // meanwhile; a failing redraw must not surface as an unhandled error. + let retried = false; + try { retried = retry() !== false; } catch { /* ignore */ } + if (retried || wasMarked) return; + // Nothing was redrawn, so nothing will verify the mark - and the layer that + // would have is gone. Leaving it standing would hand every later layer on + // this host CORS mode for good on the strength of one unproven batch. + if (BitMapHelpers._corsTileOrigins[origin!] === true) { + delete BitMapHelpers._corsTileOrigins[origin!]; + } + }; + + return { + // Wired to the provider's tile-load-start event. A tile that has started but not + // come back is the evidence that the batch is not in yet; a provider that leaves + // this unwired degrades to the plain timer, never to a wrong count. + onTileStart: () => { inFlight++; }, + onTileLoad: () => { if (inFlight > 0) inFlight--; loaded = true; }, + onTileError: () => { + if (inFlight > 0) inFlight--; + if (loaded || acted || scheduled) return; + // The first error settles nothing on its own: the tiles of a layer are + // requested in parallel, so an ordinary missing tile can report back before + // any of its siblings has finished loading. Acting on it would switch a + // perfectly reachable host to CORS mode - and break it for good when it + // sends CORP but no Access-Control-Allow-Origin. Wait for the rest of the + // batch instead and act only if none of it loaded, which is the COEP + // signature: every tile of the layer blocked, not just one. + scheduled = true; + setTimeout(settle, BitMapHelpers._tileCorsRetryDelay); + }, + }; + } + /** Convert a CSS hex color + alpha (0..1) to an rgba() string. */ static hexToRgba(hex: string | undefined, alpha: number): string { if (!hex || typeof hex !== 'string') return `rgba(51,136,255,${alpha})`; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts index f5f215e6288..cc621cb30db 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts @@ -90,16 +90,19 @@ namespace BitBlazorUI { return promise; async function addScript(url: string) { - return new Promise((res, rej) => { + return Extras.loadWithCorsFallback(url, crossOrigin => new Promise((res, rej) => { const script = document.createElement('script'); script.src = url; if (isModule) { script.type = 'module'; } - script.onload = res; - script.onerror = rej; + if (crossOrigin) { + script.crossOrigin = 'anonymous'; + } + script.onload = () => res(script); + script.onerror = () => { script.remove(); rej(new Error(`Failed to load script: ${url}`)); }; document.body.appendChild(script); - }) + })); } } @@ -128,14 +131,46 @@ namespace BitBlazorUI { return promise; async function addStylesheet(url: string) { - return new Promise((res, rej) => { + return Extras.loadWithCorsFallback(url, crossOrigin => new Promise((res, rej) => { const link = document.createElement('link'); link.href = url; link.rel = 'stylesheet'; - link.onload = res; - link.onerror = rej; + if (crossOrigin) { + link.crossOrigin = 'anonymous'; + } + link.onload = () => res(link); + link.onerror = () => { link.remove(); rej(new Error(`Failed to load stylesheet: ${url}`)); }; document.head.appendChild(link); - }) + })); + } + } + + /** + * Loads a resource in no-cors mode first and, when that fails for a cross-origin URL, + * retries once in CORS mode (crossorigin="anonymous"). + * + * When the page is cross-origin isolated with Cross-Origin-Embedder-Policy: require-corp + * (the only COEP value Safari supports, needed for the multi-threaded WebAssembly runtime), + * a cross-origin script/stylesheet is blocked unless its response carries a + * Cross-Origin-Resource-Policy header or it is requested in CORS mode. Most CDNs send + * Access-Control-Allow-Origin: * but not CORP, so the CORS retry makes them load. Under + * COEP: credentialless (Chromium/Firefox) the first attempt succeeds and no retry happens, + * so hosts without CORS headers keep working there. + */ + private static async loadWithCorsFallback(url: string, load: (crossOrigin: boolean) => Promise): Promise { + try { + return await load(false); + } catch (e) { + if (!Extras.isCrossOrigin(url)) throw e; + return await load(true); + } + } + + private static isCrossOrigin(url: string): boolean { + try { + return new URL(url, document.baseURI).origin !== window.location.origin; + } catch { + return false; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Legacy/Scripts/Legacy.ts b/src/BlazorUI/Bit.BlazorUI.Legacy/Scripts/Legacy.ts index 1ad2bb8e07e..8012188778d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Legacy/Scripts/Legacy.ts +++ b/src/BlazorUI/Bit.BlazorUI.Legacy/Scripts/Legacy.ts @@ -25,16 +25,19 @@ namespace BitBlazorUI.Legacy { return promise; async function addScript(url: string) { - return new Promise((res, rej) => { + return Utils.loadWithCorsFallback(url, crossOrigin => new Promise((res, rej) => { const script = document.createElement('script'); script.src = url; if (isModule) { script.type = 'module'; } - script.onload = res; - script.onerror = rej; + if (crossOrigin) { + script.crossOrigin = 'anonymous'; + } + script.onload = () => res(script); + script.onerror = () => { script.remove(); rej(new Error(`Failed to load script: ${url}`)); }; document.body.appendChild(script); - }) + })); } } @@ -63,14 +66,46 @@ namespace BitBlazorUI.Legacy { return promise; async function addStylesheet(url: string) { - return new Promise((res, rej) => { + return Utils.loadWithCorsFallback(url, crossOrigin => new Promise((res, rej) => { const link = document.createElement('link'); link.href = url; link.rel = 'stylesheet'; - link.onload = res; - link.onerror = rej; + if (crossOrigin) { + link.crossOrigin = 'anonymous'; + } + link.onload = () => res(link); + link.onerror = () => { link.remove(); rej(new Error(`Failed to load stylesheet: ${url}`)); }; document.head.appendChild(link); - }) + })); + } + } + + /** + * Loads a resource in no-cors mode first and, when that fails for a cross-origin URL, + * retries once in CORS mode (crossorigin="anonymous"). + * + * When the page is cross-origin isolated with Cross-Origin-Embedder-Policy: require-corp + * (the only COEP value Safari supports, needed for the multi-threaded WebAssembly runtime), + * a cross-origin script/stylesheet is blocked unless its response carries a + * Cross-Origin-Resource-Policy header or it is requested in CORS mode. Most CDNs send + * Access-Control-Allow-Origin: * but not CORP, so the CORS retry makes them load. Under + * COEP: credentialless (Chromium/Firefox) the first attempt succeeds and no retry happens, + * so hosts without CORS headers keep working there. + */ + private static async loadWithCorsFallback(url: string, load: (crossOrigin: boolean) => Promise): Promise { + try { + return await load(false); + } catch (e) { + if (!Utils.isCrossOrigin(url)) throw e; + return await load(true); + } + } + + private static isCrossOrigin(url: string): boolean { + try { + return new URL(url, document.baseURI).origin !== window.location.origin; + } catch { + return false; } } diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Controllers/CesiumController.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Controllers/CesiumController.cs new file mode 100644 index 00000000000..d2b7d9a5ec1 --- /dev/null +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Controllers/CesiumController.cs @@ -0,0 +1,103 @@ +namespace Bit.BlazorUI.Demo.Server.Controllers; + +/// +/// Same-origin passthrough for the CesiumJS build the BitMap demo loads. +/// +/// The demo site is served cross-origin isolated with Cross-Origin-Embedder-Policy: require-corp +/// (see Middlewares.cs), under which a cross-origin subresource is blocked unless its response +/// carries a Cross-Origin-Resource-Policy header or it is requested in CORS mode. cesium.com sends +/// neither CORP nor Access-Control-Allow-Origin, so neither route works and the script is blocked +/// outright. Serving it from this origin sidesteps the check entirely - COEP only constrains +/// cross-origin resources. +/// +/// +/// The whole Build/Cesium tree has to come through here, not just Cesium.js: CesiumJS resolves its +/// own workers, .wasm decoders and assets relative to the URL its script tag was loaded from, so +/// those requests land on this route as well and would otherwise go straight back to cesium.com. +/// +/// +[ApiController] +[Route("api/cesium")] +public partial class CesiumController : AppControllerBase +{ + /// + /// The only upstream this controller will ever fetch from. Every composed URL is checked + /// against this prefix after resolution, so a path containing '..' (or an absolute URL) can + /// never reach another host - this endpoint takes a caller-supplied path and would otherwise + /// be a server-side request forgery hole. + /// + /// Derived from rather than spelled out, so + /// that bumping the pinned CesiumJS release in one place cannot leave this proxy silently + /// serving the previous build to the demo. + /// + /// + private const string UpstreamBaseUrl = $"{BitCesiumMapProvider.DefaultBaseUrl}/"; + + /// Guards against a pathological path being composed into a request URL. + private const int MaxPathLength = 512; + + /// + /// How long the upstream is given to accept the request and answer with its headers. It + /// deliberately does not cover the body: that is streamed straight to the browser and so is + /// paced by the browser, which is why the named client itself carries no + /// (see Services.cs). + /// + private static readonly TimeSpan UpstreamHeadersTimeout = TimeSpan.FromSeconds(30); + + [AutoInject] private IHttpClientFactory httpClientFactory = default!; + + [HttpGet("{**path}")] + public async Task Get(string? path, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(path) || path.Length > MaxPathLength) return NotFound(); + + if (Uri.TryCreate(new Uri(UpstreamBaseUrl, UriKind.Absolute), path, out var upstreamUri) is false) return NotFound(); + + // Resolution has already collapsed any '..' segments, so comparing the *result* against the + // base is what actually confines the request - validating the raw path would not. + if (upstreamUri.AbsoluteUri.StartsWith(UpstreamBaseUrl, StringComparison.Ordinal) is false) return NotFound(); + + var httpClient = httpClientFactory.CreateClient(nameof(CesiumController)); + + // The token this send is made with also governs the reads of the streamed body below, so + // the deadline is armed for the header phase only and disarmed as soon as the headers are + // in - a slow but healthy client must not have its download cut off at the 30 s mark. It + // stays linked to RequestAborted throughout, so a client that goes away still tears the + // upstream request down. Disposed with the request rather than here: the body outlives + // this method. + var headersTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + HttpContext.Response.RegisterForDispose(headersTimeout); + headersTimeout.CancelAfter(UpstreamHeadersTimeout); + + // ResponseHeadersRead so the body streams through instead of being buffered in memory: + // Cesium.js alone is several megabytes. + var upstreamResponse = await httpClient.GetAsync(upstreamUri, HttpCompletionOption.ResponseHeadersRead, headersTimeout.Token); + + headersTimeout.CancelAfter(Timeout.InfiniteTimeSpan); + + if (upstreamResponse.IsSuccessStatusCode is false) + { + var statusCode = (int)upstreamResponse.StatusCode; + upstreamResponse.Dispose(); + return StatusCode(statusCode); + } + + // The body below is the upstream stream itself, so the response message has to outlive this + // method; hand it to the request's dispose list rather than a using block. + HttpContext.Response.RegisterForDispose(upstreamResponse); + + // The content type has to be forwarded verbatim: the tree mixes JavaScript, CSS, JSON, images, + // glTF and application/wasm, and the browser refuses a worker or a WebAssembly module served + // under the wrong type. + var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; + + // A pinned CesiumJS release is immutable, so it is safe to let the browser and the CDN hold on + // to it. The COOP/COEP headers this response also carries are UA-independent (see + // Middlewares.cs), so a shared cache entry is safe to hand to any browser. + Response.GetTypedHeaders().CacheControl = new() { Public = true, MaxAge = TimeSpan.FromDays(30) }; + + var stream = await upstreamResponse.Content.ReadAsStreamAsync(cancellationToken); + + return File(stream, contentType); + } +} diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Controllers/VideosController.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Controllers/VideosController.cs new file mode 100644 index 00000000000..4b682a91153 --- /dev/null +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Controllers/VideosController.cs @@ -0,0 +1,145 @@ +using System.Net; +using Microsoft.Net.Http.Headers; + +namespace Bit.BlazorUI.Demo.Server.Controllers; + +/// +/// Same-origin passthrough for the demo videos hosted on videos.bitplatform.dev. +/// +/// The demo site is served cross-origin isolated with Cross-Origin-Embedder-Policy: require-corp +/// (see Middlewares.cs), under which a cross-origin subresource is blocked unless its response +/// carries a Cross-Origin-Resource-Policy header or it is requested in CORS mode. The video host +/// sends neither CORP nor Access-Control-Allow-Origin, so neither route works and the <video> +/// element is left with a media error. Serving it from this origin sidesteps the check entirely - +/// COEP only constrains cross-origin resources. +/// +/// +/// Unlike the CesiumJS passthrough this one has to speak byte ranges: a media element seeks by +/// asking for one, and WebKit refuses to play a video at all from a source that does not answer +/// range requests. So the client's Range (and its conditional headers) go upstream verbatim and +/// the upstream's status - 206 and its Content-Range included - comes back verbatim, rather than +/// the response being flattened into a plain 200 by . +/// +/// +[ApiController] +[Route("api/videos")] +public partial class VideosController : AppControllerBase +{ + /// + /// The only upstream this controller will ever fetch from. Every composed URL is checked + /// against this prefix after resolution, so a path containing '..' (or an absolute URL) can + /// never reach another host - this endpoint takes a caller-supplied path and would otherwise + /// be a server-side request forgery hole. + /// + private const string UpstreamBaseUrl = "https://videos.bitplatform.dev/"; + + /// Guards against a pathological path being composed into a request URL. + private const int MaxPathLength = 512; + + /// + /// How long the upstream is given to accept the request and answer with its headers. As in + /// it deliberately does not cover the body, which is streamed + /// at the pace of the browser downloading it - all the more so here, where the browser holds + /// a media stream open for as long as it is playing. + /// + private static readonly TimeSpan UpstreamHeadersTimeout = TimeSpan.FromSeconds(30); + + /// + /// Request headers forwarded upstream: the range the media element is asking for, and the + /// validators that let the upstream answer a re-request with a 304 instead of the bytes. + /// + private static readonly string[] ForwardedRequestHeaders = + [ + HeaderNames.Range, HeaderNames.IfRange, HeaderNames.IfNoneMatch, HeaderNames.IfModifiedSince + ]; + + /// + /// Response headers forwarded back: what a 206 means (Content-Range), that ranges may be asked + /// for at all (Accept-Ranges), and the validators the browser needs to revalidate later. + /// + private static readonly string[] ForwardedResponseHeaders = + [ + HeaderNames.ContentRange, HeaderNames.AcceptRanges, HeaderNames.ETag, HeaderNames.LastModified + ]; + + [AutoInject] private IHttpClientFactory httpClientFactory = default!; + + [HttpGet("{**path}")] + public async Task Get(string? path, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(path) || path.Length > MaxPathLength) return NotFound(); + + if (Uri.TryCreate(new Uri(UpstreamBaseUrl, UriKind.Absolute), path, out var upstreamUri) is false) return NotFound(); + + // Resolution has already collapsed any '..' segments, so comparing the *result* against the + // base is what actually confines the request - validating the raw path would not. + if (upstreamUri.AbsoluteUri.StartsWith(UpstreamBaseUrl, StringComparison.Ordinal) is false) return NotFound(); + + using var upstreamRequest = new HttpRequestMessage(HttpMethod.Get, upstreamUri); + foreach (var header in ForwardedRequestHeaders) + { + if (Request.Headers.TryGetValue(header, out var values)) + { + upstreamRequest.Headers.TryAddWithoutValidation(header, (IEnumerable)values); + } + } + + var httpClient = httpClientFactory.CreateClient(nameof(VideosController)); + + // The token a send is made with also governs the reads of the streamed body below, so the + // deadline is armed for the header phase only and disarmed as soon as the headers are in - + // a video being watched must not have its stream cut off at the 30 s mark. It stays linked + // to RequestAborted throughout, so a viewer who navigates away still tears the upstream + // request down. Disposed with the request rather than here: the body outlives this method. + var headersTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + HttpContext.Response.RegisterForDispose(headersTimeout); + headersTimeout.CancelAfter(UpstreamHeadersTimeout); + + // ResponseHeadersRead so the body streams through instead of being buffered in memory: the + // demo videos are tens of megabytes. + var upstreamResponse = await httpClient.SendAsync(upstreamRequest, HttpCompletionOption.ResponseHeadersRead, headersTimeout.Token); + + headersTimeout.CancelAfter(Timeout.InfiniteTimeSpan); + + if (upstreamResponse.IsSuccessStatusCode is false && upstreamResponse.StatusCode != HttpStatusCode.NotModified) + { + var statusCode = (int)upstreamResponse.StatusCode; + upstreamResponse.Dispose(); + return StatusCode(statusCode); + } + + // The body below is the upstream stream itself, so the response message has to outlive this + // method; hand it to the request's dispose list rather than a using block. + HttpContext.Response.RegisterForDispose(upstreamResponse); + + Response.StatusCode = (int)upstreamResponse.StatusCode; + + foreach (var header in ForwardedResponseHeaders) + { + var values = upstreamResponse.Headers.TryGetValues(header, out var fromMessage) + ? fromMessage + : (upstreamResponse.Content.Headers.TryGetValues(header, out var fromContent) ? fromContent : null); + + if (values is not null) + { + Response.Headers[header] = values.ToArray(); + } + } + + // The demo videos are immutable, so it is safe to let the browser and the CDN hold on to + // them. The COOP/COEP headers this response also carries are UA-independent (see + // Middlewares.cs), so a shared cache entry is safe to hand to any browser. + Response.GetTypedHeaders().CacheControl = new() { Public = true, MaxAge = TimeSpan.FromDays(30) }; + + if (upstreamResponse.StatusCode == HttpStatusCode.NotModified) return new EmptyResult(); + + Response.ContentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "video/mp4"; + Response.ContentLength = upstreamResponse.Content.Headers.ContentLength; + + var stream = await upstreamResponse.Content.ReadAsStreamAsync(cancellationToken); + + await stream.CopyToAsync(Response.Body, cancellationToken); + + return new EmptyResult(); + } +} diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Middlewares.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Middlewares.cs index 8a5507a76b2..a4fdbc7960c 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Middlewares.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Middlewares.cs @@ -19,14 +19,56 @@ public static void Use(WebApplication app, IWebHostEnvironment env, IConfigurati // Cross-origin isolation, required for the multi-threaded WebAssembly runtime // (WasmEnableThreads in Bit.BlazorUI.Demo.Client.Web) to use SharedArrayBuffer. // Without these headers on the top-level document, crossOriginIsolated stays - // false and the runtime silently falls back to a single thread. COEP - // 'credentialless' is used instead of 'require-corp' so cross-origin - // subresources (fonts, images) keep loading without their own CORP/CORS headers; - // it enables isolation on Chromium and Firefox (Safari needs 'require-corp'). + // false and the threaded runtime refuses to start ("SharedArrayBuffer is not + // enabled on this page"). + // + // The same value goes to every browser, deliberately. 'credentialless' is the more + // permissive choice - it lets cross-origin subresources load without their own + // CORP/CORS headers - but Safari (WebKit, i.e. every browser on iOS) does not + // understand it and treats it as 'unsafe-none', so a WebKit client sent + // 'credentialless' never becomes cross-origin isolated. Picking the value per + // User-Agent looks like the obvious fix and is a trap: this site sits behind a CDN + // that caches these responses (Cloudflare honours only 'Vary: Accept-Encoding', so + // 'Vary: User-Agent' does not fragment its cache), and COEP is read from every + // response that establishes an embedder policy - the top-level document *and* every + // dedicated worker script, which HTML's "check a global object's embedder policy" + // rejects when its value is not compatible with the owner document's. A single + // cached copy of '_framework/dotnet.native.worker.mjs' shared between browsers + // would therefore break whichever browser did not warm the edge, non-deterministically. + // A UA-independent 'require-corp' has no such failure mode. + // + // The cost is that under 'require-corp' every cross-origin subresource must carry a + // Cross-Origin-Resource-Policy header or be loaded in CORS mode + // (crossorigin="anonymous"). That is why the demo pages use local images, the + // Extras/Legacy script loaders retry in CORS mode, the map providers retry their + // tiles in CORS mode, and CesiumJS - which sends neither CORP nor CORS - is proxied + // same-origin through CesiumController. + // + // Set from OnStarting rather than before next.Invoke: UseExceptionHandler re-executes + // the pipeline from its own position after clearing the response headers, so a header + // written here on the way in would be missing from the error page it renders. app.Use(async (context, next) => { - context.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin"; - context.Response.Headers["Cross-Origin-Embedder-Policy"] = "credentialless"; + context.Response.OnStarting(() => + { + context.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin"; + context.Response.Headers["Cross-Origin-Embedder-Policy"] = "require-corp"; + + // Worker scripts are the one response whose COEP value is compared against + // another's - the owner document's - so a cached copy from before this site's COEP + // changed makes the runtime fail to boot until that copy expires. Both the CDN and + // the browser were holding '_framework/dotnet.native.worker.mjs' for a day + // (max-age=86400), so force a revalidation on it instead: it is a few KB fetched + // once per session, and it takes a whole class of "stale COEP" boot failures off + // the table for good, not just for the deploy that introduced require-corp. + if (context.Request.Path.StartsWithSegments("/_framework") && + context.Request.Path.Value?.EndsWith(".mjs", StringComparison.OrdinalIgnoreCase) is true) + { + context.Response.GetTypedHeaders().CacheControl = new() { NoCache = true }; + } + + return Task.CompletedTask; + }); await next.Invoke(context); }); diff --git a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Services.cs b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Services.cs index ec02cf293bc..d6e8e0d6642 100644 --- a/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Services.cs +++ b/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Startup/Services.cs @@ -1,6 +1,7 @@ using System.ClientModel.Primitives; using System.IO.Compression; using Bit.BlazorUI.Demo.Server.Services; +using Bit.BlazorUI.Demo.Server.Controllers; using Microsoft.AspNetCore.Components.Web; using Bit.BlazorUI.Demo.Client.Core.Components; using Bit.BlazorUI.Demo.Client.Core.Services; @@ -25,6 +26,19 @@ public static void Add(IServiceCollection services, IWebHostEnvironment env, ICo services.AddHttpClient(); services.AddScoped(); + // Upstream client for the same-origin CesiumJS passthrough (see CesiumController). + // No HttpClient.Timeout: it is a deadline for the whole exchange, the streamed body + // included, and that body is drained at the pace of the browser downloading it - a client + // on a slow link would have the multi-megabyte Cesium.js truncated mid-response. The + // controller puts its own deadline on the part that is actually the server's to bound, + // reaching the upstream and getting its headers back. + services.AddHttpClient(nameof(CesiumController), client => client.Timeout = Timeout.InfiniteTimeSpan); + + // Upstream client for the same-origin demo-video passthrough (see VideosController), with + // no Timeout for the same reason - all the more so there, where the body is a media stream + // the browser holds open for as long as the video is playing. + services.AddHttpClient(nameof(VideosController), client => client.Timeout = Timeout.InfiniteTimeSpan); + services.AddExceptionHandler(); services.AddBlazor(configuration); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor index 0b8c1bd2b67..726130ed2e7 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor @@ -211,7 +211,12 @@ else { - } @if (Introduction.HasValue() || IntroductionTemplate is not null) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs index b4d626b99e9..ec5a3cab5e5 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs @@ -4,6 +4,9 @@ public partial class DemoPage { private const string REPO_URL = "https://github.com/bitfoundation/bitplatform"; + /// Host of the demo videos, which are proxied same-origin - see . + private const string VIDEOS_BASE_URL = "https://videos.bitplatform.dev/"; + /// The element the visibility observer watches to know the reader has reached the API tables. private const string API_ELEMENT_ID = "api-tables"; @@ -89,6 +92,25 @@ public partial class DemoPage [CascadingParameter(Name = nameof(RenderForMcpClient))] public bool RenderForMcpClient { get; set; } + /// + /// The url the <video> element is actually given. The web demo is served cross-origin + /// isolated with Cross-Origin-Embedder-Policy: require-corp (see Middlewares.cs), under which a + /// cross-origin subresource is blocked unless it carries a Cross-Origin-Resource-Policy header + /// or is requested in CORS mode; the video host sends neither CORP nor + /// Access-Control-Allow-Origin, so both routes are dead ends and the video simply does not + /// play. It therefore comes through the same-origin passthrough of VideosController instead - + /// COEP only constrains cross-origin resources. + /// + /// Blazor Hybrid keeps the url verbatim: its origin (app://0.0.0.0) hosts no such endpoint, and + /// a hybrid WebView is not cross-origin isolated, so the direct url both works and is the only + /// one that resolves there. Same reasoning as the CesiumJS BaseUrl in BitMapDemo. + /// + /// + private string? _introductionVideoUrl => + AppRenderMode.IsBlazorHybrid || IntroductionVideoUrl?.StartsWith(VIDEOS_BASE_URL, StringComparison.OrdinalIgnoreCase) is not true + ? IntroductionVideoUrl + : $"/api/videos/{IntroductionVideoUrl![VIDEOS_BASE_URL.Length..]}"; + /// /// Whether the API tables may wait - and, when they may, how much room they have to keep meanwhile. /// The same two cases distinguishes for its own preview: a page built on diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor index 48b054d0655..2c6461bc37d 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor @@ -247,6 +247,16 @@ Cesium ion dashboard, grant only the asset access it needs, and add your production domains to the token's allowed URLs list so a copied token cannot be used from other origins. +

+ Note (cross-origin isolation): this demo site is served cross-origin isolated with + Cross-Origin-Embedder-Policy: require-corp to enable the multi-threaded WebAssembly + runtime, and under that header a cross-origin script must carry a + Cross-Origin-Resource-Policy header or be loaded in CORS mode. The CesiumJS build on + cesium.com sends neither, so the browser blocks it outright. This example therefore loads + CesiumJS from this site's own origin instead, via the provider's BaseUrl parameter - point + it at a copy of the Build/Cesium folder you host or proxy yourself. Note that CesiumJS + resolves its workers, .wasm decoders and assets relative to that URL, so the whole folder + has to be reachable under it. Your own app needs none of this unless it is cross-origin isolated too.
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor.cs index e86ba897b55..90543df3c59 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Map/BitMapDemo.razor.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Map; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Map; public partial class BitMapDemo { @@ -261,7 +261,21 @@ public partial class BitMapDemo private readonly BitMapLibreMapProvider maplibreProvider = new() { Center = new(48.8566, 2.3522), Zoom = 5 }; private readonly BitOpenLayersMapProvider olProvider = new() { Center = new(35.6762, 139.6503), Zoom = 4 }; private readonly BitArcGisMapProvider arcGisProvider = new() { Center = new(40, 0), Zoom = 2, BasemapId = "osm" }; - private readonly BitCesiumMapProvider cesiumProvider = new() { Center = new(20, 0), Zoom = 2, SceneMode = "scene3d" }; + // On the web the BaseUrl points at this site's own CesiumJS passthrough (CesiumController) + // rather than the cesium.com CDN: the demo is served with Cross-Origin-Embedder-Policy: + // require-corp, and cesium.com sends neither Cross-Origin-Resource-Policy nor + // Access-Control-Allow-Origin, so the browser blocks its script in both no-cors and CORS mode. + // Serving it same-origin sidesteps the check. Your own app only needs this if it is + // cross-origin isolated too - which is why the MAUI/Windows hybrid clients keep the default: + // their origin (app://0.0.0.0) hosts no such endpoint, and a hybrid WebView is not + // cross-origin isolated, so the CDN URL both works and is the only one that resolves there. + private readonly BitCesiumMapProvider cesiumProvider = new() + { + Center = new(20, 0), + Zoom = 2, + SceneMode = "scene3d", + BaseUrl = AppRenderMode.IsBlazorHybrid ? BitCesiumMapProvider.DefaultBaseUrl : "/api/cesium", + }; // ── Example 2 – Markers ─────────────────────────────────────────────────── @@ -1127,5 +1141,10 @@ private Task OnAdvancedDoubleClick(BitMapLatLng p)
"; private readonly string example13CsharpCode = @" // Bind a stable field so the provider isn't reallocated on every render. -private readonly BitCesiumMapProvider cesiumProvider = new() { Center = new(20, 0), Zoom = 2, SceneMode = ""scene3d"" };"; +private readonly BitCesiumMapProvider cesiumProvider = new() { Center = new(20, 0), Zoom = 2, SceneMode = ""scene3d"" }; + +// Only needed when your page is cross-origin isolated (COEP: require-corp): cesium.com sends +// neither a Cross-Origin-Resource-Policy nor an Access-Control-Allow-Origin header, so the +// script is blocked. Point BaseUrl at a copy of the Build/Cesium folder on your own origin. +// private readonly BitCesiumMapProvider cesiumProvider = new() { ..., BaseUrl = ""/api/cesium"" };"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/RichTextEditor/BitRichTextEditorDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/RichTextEditor/BitRichTextEditorDemo.razor index 0b62771b81a..d0b0b6930bf 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/RichTextEditor/BitRichTextEditorDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/RichTextEditor/BitRichTextEditorDemo.razor @@ -216,6 +216,17 @@ The Media group embeds a direct audio/video URL as a native video/audio element. The Rule group inserts a horizontal divider. Note: iframe embeds (e.g. YouTube/Vimeo) are not kept by the default sanitization policy, so supply a custom policy that allows the iframe tag if you need such embeds. +

+ Note: this demo site is served cross-origin isolated with + Cross-Origin-Embedder-Policy: require-corp to enable the multi-threaded WebAssembly + runtime. Under COEP a cross-origin iframe (YouTube/Vimeo) is blocked unless the embedded page opts + in. A direct audio/video URL is fetched in no-cors mode unless the media element itself carries + crossorigin="anonymous" (an attribute the default sanitization policy drops, so it takes + a custom policy to keep). So a cross-origin media URL is blocked unless its server sends a + Cross-Origin-Resource-Policy header for the no-cors request, or an + Access-Control-Allow-Origin header when the element opts into anonymous CORS mode. Media + from your own origin, or from hosts that send those headers, is unaffected; so is any app that does not + send COEP.
+ ImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-unselected.png" + SelectedImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-selected.png" /> + ImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-unselected.png" + SelectedImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-selected.png" /> + ImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-unselected.png" + SelectedImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-selected.png" /> + ImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-unselected.png" + SelectedImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-selected.png" /> + ImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-unselected.png" + SelectedImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-selected.png" /> + ImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-unselected.png" + SelectedImageSrc="/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-selected.png" /> diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupOptionDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupOptionDemo.razor.samples.cs index 47e8fa48689..a5a93b8eb0e 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupOptionDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupOptionDemo.razor.samples.cs @@ -45,14 +45,14 @@ public partial class _BitChoiceGroupOptionDemo Value=""@(""Bar"")"" ImageAlt=""Alt for Bar image"" ImageSize=""@(new BitImageSize(32, 32))"" - ImageSrc=""https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/choicegroup-bar-unselected.png"" - SelectedImageSrc=""https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/choicegroup-bar-selected.png"" /> + ImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-unselected.png"" + SelectedImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-selected.png"" /> + ImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-unselected.png"" + SelectedImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-selected.png"" /> + ImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-unselected.png"" + SelectedImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-selected.png"" /> + ImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-unselected.png"" + SelectedImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-selected.png"" /> + ImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-unselected.png"" + SelectedImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-bar-selected.png"" /> + ImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-unselected.png"" + SelectedImageSrc=""/_content/Bit.BlazorUI.Demo.Client.Core/images/choicegroup/choicegroup-pie-selected.png"" /> "" TValue=""string"" DefaultValue=""@(""Day"")"" Horizontal> diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor index 45972d2b5c0..ee7fb3bf185 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor @@ -337,7 +337,7 @@ - +


diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor.samples.cs index 350e31d57d2..4cf6e5ffda3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/CircularTimePicker/BitCircularTimePickerDemo.razor.samples.cs @@ -202,7 +202,7 @@ private static BitTimeFormat GetTimeFormatOf(CultureInfo culture) IconLocation=""BitIconLocation.Left"" Placeholder=""Select a time""> - + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor index 2961e94954a..c78c2c4cded 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor @@ -542,7 +542,7 @@

- +

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor.samples.cs index 22106e31983..b9ca6fc56b3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TimePicker/BitTimePickerDemo.razor.samples.cs @@ -309,7 +309,7 @@ private void Log(string message) - + diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Legacy/MarkdownViewer/BitMarkdownViewerLegacyDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Legacy/MarkdownViewer/BitMarkdownViewerLegacyDemo.razor.cs index 021b36048a0..bf5a375d906 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Legacy/MarkdownViewer/BitMarkdownViewerLegacyDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Legacy/MarkdownViewer/BitMarkdownViewerLegacyDemo.razor.cs @@ -62,16 +62,16 @@ The middleware receives the parsed HTML string and returns the processed HTML st - private string advancedMarkdown = @"![Header](https://user-images.githubusercontent.com/6169846/251658486-b16e1db8-5481-46c4-9fc1-c9b279a4364a.png) + private string advancedMarkdown = @"![Header](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/bitplatform-banner.webp)
![License](https://img.shields.io/github/license/bitfoundation/bitplatform.svg) -![CI Status](https://github.com/bitfoundation/bitplatform/actions/workflows/bit.ci.yml/badge.svg) +![CI Status](https://img.shields.io/github/actions/workflow/status/bitfoundation/bitplatform/bit.ci.BlazorUI.yml?logo=github) ![NuGet version](https://img.shields.io/nuget/v/bit.blazorui.svg?logo=nuget) [![Nuget downloads](https://img.shields.io/badge/packages_download-8.2M-blue.svg?logo=nuget)](https://www.nuget.org/profiles/bit-foundation) -[![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/bitfoundation/bitplatform.svg)](http://isitmaintained.com/project/bitfoundation/bitplatform ""Average time to resolve an issue"") -[![Percentage of issues still open](http://isitmaintained.com/badge/open/bitfoundation/bitplatform.svg)](http://isitmaintained.com/project/bitfoundation/bitplatform ""Percentage of issues still open"") +[![Closed issues](https://img.shields.io/github/issues-closed/bitfoundation/bitplatform?logo=github)](https://isitmaintained.com/project/bitfoundation/bitplatform ""Closed issues"") +[![Open issues](https://img.shields.io/github/issues/bitfoundation/bitplatform?logo=github)](https://isitmaintained.com/project/bitfoundation/bitplatform ""Open issues"")
@@ -103,11 +103,11 @@ The middleware receives the parsed HTML string and returns the processed HTML st | |    Web    |    iOS    | Android | Windows | macOS | |:-:|:--:|:--:|:--:|:--:|:--:| -| bitplatform | [![PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://bitplatform.dev)| *N/A* | *N/A* | *N/A* | *N/A* | -| Sales | [![PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://sales.bitplatform.dev) | *Soon!* | *Soon!* | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-sales.bitplatform.dev/SalesModule.Client.Windows-win-Setup.exe) | *Soon!* | -| bit BlazorUI | [![Prerendered PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://blazorui.bitplatform.dev) | [![iOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381842-e72976ce-fd20-431d-a677-ca1ed625b83b.png)](https://apps.apple.com/us/app/bit-blazor-ui/id6450401404) | [![Android app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381958-24931682-87f6-44fc-a1c7-eecf46387005.png)](https://play.google.com/store/apps/details?id=com.bitplatform.BlazorUI.Demo) | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-components.bitplatform.dev/Bit.BlazorUI.Demo.Client.Windows-win-Setup.exe) | [![macOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382211-0d58f9ba-1a1f-4481-a0ca-b23a393cca9f.png)](https://apps.apple.com/nl/app/bit-blazor-ui/id6450401404) -| AdminPanel | [![Prerendered PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://adminpanel.bitplatform.dev) | [![iOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381842-e72976ce-fd20-431d-a677-ca1ed625b83b.png)](https://apps.apple.com/us/app/bit-adminpanel/id6450611349) | [![Android app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381958-24931682-87f6-44fc-a1c7-eecf46387005.png)](https://play.google.com/store/apps/details?id=com.bitplatform.AdminPanel.Template) | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-admin.bitplatform.dev/AdminPanel.Client.Windows-win-Setup.exe) | [![macOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382211-0d58f9ba-1a1f-4481-a0ca-b23a393cca9f.png)](https://apps.apple.com/nl/app/bit-adminpanel/id6450611349) | -| Todo | [![Prerendered PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://todo.bitplatform.dev) | [![iOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381842-e72976ce-fd20-431d-a677-ca1ed625b83b.png)](https://apps.apple.com/us/app/bit-todotemplate/id6450611072) | [![Android app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381958-24931682-87f6-44fc-a1c7-eecf46387005.png)](https://play.google.com/store/apps/details?id=com.bitplatform.Todo.Template) | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-todo.bitplatform.dev/TodoSample.Client.Windows-win-Setup.exe) | [![macOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382211-0d58f9ba-1a1f-4481-a0ca-b23a393cca9f.png)](https://apps.apple.com/nl/app/bit-todotemplate/id6450611072) +| bitplatform | [![PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://bitplatform.dev)| *N/A* | *N/A* | *N/A* | *N/A* | +| Sales | [![PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://sales.bitplatform.dev) | *Soon!* | *Soon!* | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-sales.bitplatform.dev/SalesModule.Client.Windows-win-Setup.exe) | *Soon!* | +| bit BlazorUI | [![Prerendered PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://blazorui.bitplatform.dev) | [![iOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-ios.png)](https://apps.apple.com/us/app/bit-blazor-ui/id6450401404) | [![Android app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-android.png)](https://play.google.com/store/apps/details?id=com.bitplatform.BlazorUI.Demo) | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-components.bitplatform.dev/Bit.BlazorUI.Demo.Client.Windows-win-Setup.exe) | [![macOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-macos.png)](https://apps.apple.com/nl/app/bit-blazor-ui/id6450401404) +| AdminPanel | [![Prerendered PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://adminpanel.bitplatform.dev) | [![iOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-ios.png)](https://apps.apple.com/us/app/bit-adminpanel/id6450611349) | [![Android app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-android.png)](https://play.google.com/store/apps/details?id=com.bitplatform.AdminPanel.Template) | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-admin.bitplatform.dev/AdminPanel.Client.Windows-win-Setup.exe) | [![macOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-macos.png)](https://apps.apple.com/nl/app/bit-adminpanel/id6450611349) | +| Todo | [![Prerendered PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://todo.bitplatform.dev) | [![iOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-ios.png)](https://apps.apple.com/us/app/bit-todotemplate/id6450611072) | [![Android app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-android.png)](https://play.google.com/store/apps/details?id=com.bitplatform.Todo.Template) | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-todo.bitplatform.dev/TodoSample.Client.Windows-win-Setup.exe) | [![macOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-macos.png)](https://apps.apple.com/nl/app/bit-todotemplate/id6450611072) 1. [bitplatform.dev](https://bitplatform.dev): .NET 9 Pre-rendered PWA with Blazor WebAssembly (Azure Web App + Cloudflare CDN) 2. [sales.bitplatform.dev](https://sales.bitplatform.dev): .NET 9 Sales Pre-rendered PWA with Blazor WebAssembly (Azure Web App + Cloudflare CDN) @@ -135,7 +135,7 @@ We welcome contributions! Many people all over the world have helped make this p # **Contributions** -![Alt](https://repobeats.axiom.co/api/embed/66dc1fc04ed967094b98ac118e8f18fa38b19f6a.svg ""bit platform open source contributions report"")"; +![Alt](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/repobeats.svg ""bit platform open source contributions report"")"; private DateTimeOffset? parsingDateTime; private DateTimeOffset? parsedDateTime; @@ -171,16 +171,16 @@ private void OnRendered(string? html) "; private readonly string example2CsharpCode = @" -private string advancedMarkdown = @""![Header](https://user-images.githubusercontent.com/6169846/251658486-b16e1db8-5481-46c4-9fc1-c9b279a4364a.png) +private string advancedMarkdown = @""![Header](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/bitplatform-banner.webp)
![License](https://img.shields.io/github/license/bitfoundation/bitplatform.svg) -![CI Status](https://github.com/bitfoundation/bitplatform/actions/workflows/bit.ci.yml/badge.svg) +![CI Status](https://img.shields.io/github/actions/workflow/status/bitfoundation/bitplatform/bit.ci.BlazorUI.yml?logo=github) ![NuGet version](https://img.shields.io/nuget/v/bit.blazorui.svg?logo=nuget) [![Nuget downloads](https://img.shields.io/badge/packages_download-8.2M-blue.svg?logo=nuget)](https://www.nuget.org/profiles/bit-foundation) -[![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/bitfoundation/bitplatform.svg)](http://isitmaintained.com/project/bitfoundation/bitplatform """"Average time to resolve an issue"""") -[![Percentage of issues still open](http://isitmaintained.com/badge/open/bitfoundation/bitplatform.svg)](http://isitmaintained.com/project/bitfoundation/bitplatform """"Percentage of issues still open"""") +[![Closed issues](https://img.shields.io/github/issues-closed/bitfoundation/bitplatform?logo=github)](https://isitmaintained.com/project/bitfoundation/bitplatform """"Closed issues"""") +[![Open issues](https://img.shields.io/github/issues/bitfoundation/bitplatform?logo=github)](https://isitmaintained.com/project/bitfoundation/bitplatform """"Open issues"""")
@@ -212,11 +212,11 @@ private void OnRendered(string? html) | |    Web    |    iOS    | Android | Windows | macOS | |:-:|:--:|:--:|:--:|:--:|:--:| -| bitplatform | [![PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://bitplatform.dev)| *N/A* | *N/A* | *N/A* | *N/A* | -| Sales | [![PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://sales.bitplatform.dev) | *Soon!* | *Soon!* | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-sales.bitplatform.dev/SalesModule.Client.Windows-win-Setup.exe) | *Soon!* | -| bit BlazorUI | [![Prerendered PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://blazorui.bitplatform.dev) | [![iOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381842-e72976ce-fd20-431d-a677-ca1ed625b83b.png)](https://apps.apple.com/us/app/bit-blazor-ui/id6450401404) | [![Android app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381958-24931682-87f6-44fc-a1c7-eecf46387005.png)](https://play.google.com/store/apps/details?id=com.bitplatform.BlazorUI.Demo) | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-components.bitplatform.dev/Bit.BlazorUI.Demo.Client.Windows-win-Setup.exe) | [![macOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382211-0d58f9ba-1a1f-4481-a0ca-b23a393cca9f.png)](https://apps.apple.com/nl/app/bit-blazor-ui/id6450401404) -| AdminPanel | [![Prerendered PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://adminpanel.bitplatform.dev) | [![iOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381842-e72976ce-fd20-431d-a677-ca1ed625b83b.png)](https://apps.apple.com/us/app/bit-adminpanel/id6450611349) | [![Android app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381958-24931682-87f6-44fc-a1c7-eecf46387005.png)](https://play.google.com/store/apps/details?id=com.bitplatform.AdminPanel.Template) | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-admin.bitplatform.dev/AdminPanel.Client.Windows-win-Setup.exe) | [![macOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382211-0d58f9ba-1a1f-4481-a0ca-b23a393cca9f.png)](https://apps.apple.com/nl/app/bit-adminpanel/id6450611349) | -| Todo | [![Prerendered PWA](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381583-8b8eb895-80c9-4811-9641-57a5a08db163.png)](https://todo.bitplatform.dev) | [![iOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381842-e72976ce-fd20-431d-a677-ca1ed625b83b.png)](https://apps.apple.com/us/app/bit-todotemplate/id6450611072) | [![Android app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251381958-24931682-87f6-44fc-a1c7-eecf46387005.png)](https://play.google.com/store/apps/details?id=com.bitplatform.Todo.Template) | [![Windows app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382080-9ae97fea-934c-4097-aca4-124a2aed1595.png)](https://windows-todo.bitplatform.dev/TodoSample.Client.Windows-win-Setup.exe) | [![macOS app](https://github-production-user-asset-6210df.s3.amazonaws.com/6169846/251382211-0d58f9ba-1a1f-4481-a0ca-b23a393cca9f.png)](https://apps.apple.com/nl/app/bit-todotemplate/id6450611072) +| bitplatform | [![PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://bitplatform.dev)| *N/A* | *N/A* | *N/A* | *N/A* | +| Sales | [![PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://sales.bitplatform.dev) | *Soon!* | *Soon!* | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-sales.bitplatform.dev/SalesModule.Client.Windows-win-Setup.exe) | *Soon!* | +| bit BlazorUI | [![Prerendered PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://blazorui.bitplatform.dev) | [![iOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-ios.png)](https://apps.apple.com/us/app/bit-blazor-ui/id6450401404) | [![Android app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-android.png)](https://play.google.com/store/apps/details?id=com.bitplatform.BlazorUI.Demo) | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-components.bitplatform.dev/Bit.BlazorUI.Demo.Client.Windows-win-Setup.exe) | [![macOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-macos.png)](https://apps.apple.com/nl/app/bit-blazor-ui/id6450401404) +| AdminPanel | [![Prerendered PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://adminpanel.bitplatform.dev) | [![iOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-ios.png)](https://apps.apple.com/us/app/bit-adminpanel/id6450611349) | [![Android app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-android.png)](https://play.google.com/store/apps/details?id=com.bitplatform.AdminPanel.Template) | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-admin.bitplatform.dev/AdminPanel.Client.Windows-win-Setup.exe) | [![macOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-macos.png)](https://apps.apple.com/nl/app/bit-adminpanel/id6450611349) | +| Todo | [![Prerendered PWA](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-pwa.png)](https://todo.bitplatform.dev) | [![iOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-ios.png)](https://apps.apple.com/us/app/bit-todotemplate/id6450611072) | [![Android app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-android.png)](https://play.google.com/store/apps/details?id=com.bitplatform.Todo.Template) | [![Windows app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-windows.png)](https://windows-todo.bitplatform.dev/TodoSample.Client.Windows-win-Setup.exe) | [![macOS app](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/badge-macos.png)](https://apps.apple.com/nl/app/bit-todotemplate/id6450611072) 1. [bitplatform.dev](https://bitplatform.dev): .NET 9 Pre-rendered PWA with Blazor WebAssembly (Azure Web App + Cloudflare CDN) 2. [sales.bitplatform.dev](https://sales.bitplatform.dev): .NET 9 Sales Pre-rendered PWA with Blazor WebAssembly (Azure Web App + Cloudflare CDN) @@ -244,7 +244,7 @@ We welcome contributions! Many people all over the world have helped make this p # **Contributions** -![Alt](https://repobeats.axiom.co/api/embed/66dc1fc04ed967094b98ac118e8f18fa38b19f6a.svg """"bit platform open source contributions report"""")"";"; +![Alt](/_content/Bit.BlazorUI.Demo.Client.Core/images/markdown/repobeats.svg """"bit platform open source contributions report"""")"";"; private readonly string example3RazorCode = @"
- +

Id: @person.Id

Full Name: @person.FirstName @person.LastName

@@ -150,7 +150,7 @@ Style="border: 1px #a19f9d solid; border-radius: 4px;">
- +

Id: @person.Id

Full Name: @person.FirstName @person.LastName

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/BasicList/BitBasicListDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/BasicList/BitBasicListDemo.razor.samples.cs index 6a0f664a438..0e00d93e838 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/BasicList/BitBasicListDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/BasicList/BitBasicListDemo.razor.samples.cs @@ -137,7 +137,7 @@ public class Person Style=""border: 1px #a19f9d solid; border-radius: 4px;"">
- +

Id: @person.Id

Full Name: @person.FirstName @person.LastName

@@ -171,7 +171,7 @@ public class Person Style=""border: 1px #a19f9d solid; border-radius: 4px;"">
- +

Id: @person.Id

Full Name: @person.FirstName @person.LastName

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor index 5048b03cf72..0149d258cfe 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor @@ -419,7 +419,7 @@ FirstContent = (item => @), + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png" />), DotContent = (item => @
), @@ -447,7 +447,7 @@ FirstContent = (item => @), + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-male.png" />), DotContent = (item => @
), diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor.samples.cs index 1364a4cbfb9..fd8dee8aa62 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineCustomDemo.razor.samples.cs @@ -267,7 +267,7 @@ public class Event FirstContent = (item => @), + ImageUrl=""/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png"" />), DotContent = (item => @
), @@ -295,7 +295,7 @@ public class Event FirstContent = (item => @), + ImageUrl=""/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-male.png"" />), DotContent = (item => @
), diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor index 4fbd2b48d7f..bc0ca2a1b2f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor @@ -418,7 +418,7 @@ PrimaryContent = (item => @), + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png" />), DotTemplate = (item => @
), @@ -446,7 +446,7 @@ PrimaryContent = (item => @), + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-male.png" />), DotTemplate = (item => @
), diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor.samples.cs index d5eca229a5d..8a272a0c146 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineItemDemo.razor.samples.cs @@ -207,7 +207,7 @@ public partial class _BitTimelineItemDemo PrimaryContent = (item => @), + ImageUrl=""/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png"" />), DotTemplate = (item => @
), @@ -235,7 +235,7 @@ public partial class _BitTimelineItemDemo PrimaryContent = (item => @), + ImageUrl=""/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-male.png"" />), DotTemplate = (item => @
), diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor index b1dccde8190..a8559bb9680 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor @@ -291,7 +291,7 @@ + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png" />
@@ -328,7 +328,7 @@ + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-male.png" />
@@ -351,7 +351,7 @@ + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png" />
@@ -388,7 +388,7 @@ + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-male.png" />
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor.samples.cs index 90bfcec1f8c..aae3235b592 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Lists/Timeline/_BitTimelineOptionDemo.razor.samples.cs @@ -215,7 +215,7 @@ public partial class _BitTimelineOptionDemo + ImageUrl=""/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png"" />
@@ -252,7 +252,7 @@ public partial class _BitTimelineOptionDemo + ImageUrl=""/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-male.png"" />
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Progress/Shimmer/BitShimmerDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Progress/Shimmer/BitShimmerDemo.razor index f2c0872a3bc..93200656380 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Progress/Shimmer/BitShimmerDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Progress/Shimmer/BitShimmerDemo.razor @@ -73,7 +73,7 @@ SecondaryText="Software Engineer" Size="@BitPersonaSize.Size56" Presence="@BitPersonaPresence.Online" - ImageUrl="https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/persona-female.png" /> + ImageUrl="/_content/Bit.BlazorUI.Demo.Client.Core/images/persona/persona-female.png" />