Skip to content

Commit aecb261

Browse files
committed
Merge branch 'main'
2 parents c18b4cc + b6921fd commit aecb261

File tree

6 files changed

+295
-3
lines changed

6 files changed

+295
-3
lines changed

ControlR.Web.Client/Components/Permissions/UsersTabContent.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
<MudIconButton Color="Color.Error"
3434
ButtonType="ButtonType.Button"
3535
Icon="@(Icons.Material.Filled.Delete)"
36-
Disabled="@(_selectedUser == null)"
36+
Disabled="@(_selectedUser == null || _selectedUser.Id == _currentUserId)"
3737
OnClick="DeleteSelectedUser" />
3838
</MudTooltip>
3939
</div>

ControlR.Web.Client/Components/Permissions/UsersTabContent.razor.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using ControlR.Web.Client.Extensions;
2-
using ControlR.Web.Client.StateManagement.Stores;
32
using Microsoft.AspNetCore.Components;
43
using Microsoft.AspNetCore.Components.Authorization;
54
using System.Collections.Immutable;
@@ -87,7 +86,7 @@ protected override async Task OnInitializedAsync()
8786

8887
private async Task DeleteSelectedUser()
8988
{
90-
if (_selectedUser is null)
89+
if (_selectedUser is null || _selectedUser.Id == _currentUserId)
9190
return;
9291

9392
try

ControlR.Web.Client/Services/JsInterop.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ public interface IJsInterop
1111

1212
ValueTask Alert(string message);
1313

14+
ValueTask<bool> ClearWasmCache();
15+
1416
ValueTask<bool> Confirm(string message);
1517
ValueTask<string?> CreateBlobUrl(byte[] imageData, string mimeType);
1618
ValueTask<string> GetClipboardText();
1719

1820
ValueTask<int> GetCursorIndex(ElementReference inputElement);
1921
ValueTask<int> GetCursorIndexById(string inputElementId);
2022
ValueTask<bool> GetSystemDarkMode();
23+
ValueTask<WasmCacheStatus?> GetWasmCacheStatus();
2124

2225
ValueTask InvokeClick(string elementId);
2326
ValueTask<bool> IsTouchScreen();
@@ -62,6 +65,11 @@ public ValueTask Alert(string message)
6265
return jsRuntime.InvokeVoidAsync("invokeAlert", message);
6366
}
6467

68+
public ValueTask<bool> ClearWasmCache()
69+
{
70+
return jsRuntime.InvokeAsync<bool>("clearWasmCache");
71+
}
72+
6573
public ValueTask<bool> Confirm(string message)
6674
{
6775
return jsRuntime.InvokeAsync<bool>("invokeConfirm", message);
@@ -92,6 +100,11 @@ public ValueTask<bool> GetSystemDarkMode()
92100
return jsRuntime.InvokeAsync<bool>("getSystemDarkMode");
93101
}
94102

103+
public ValueTask<WasmCacheStatus?> GetWasmCacheStatus()
104+
{
105+
return jsRuntime.InvokeAsync<WasmCacheStatus?>("getWasmCacheStatus");
106+
}
107+
95108
public ValueTask InvokeClick(string elementId)
96109
{
97110
return jsRuntime.InvokeVoidAsync("invokeClick", elementId);
@@ -171,4 +184,13 @@ public ValueTask ToggleFullscreen(ElementReference? element = null)
171184
{
172185
return jsRuntime.InvokeVoidAsync("toggleFullscreen", element);
173186
}
187+
}
188+
189+
/// <summary>
190+
/// Represents the status of the WASM cache service worker.
191+
/// </summary>
192+
public sealed class WasmCacheStatus
193+
{
194+
public string CacheName { get; set; } = string.Empty;
195+
public int EntryCount { get; set; }
174196
}

ControlR.Web.Server/Components/App.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<meta charset="utf-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<base href="/" />
8+
<script src="wasm-cache-init.js"></script>
89
<ResourcePreloader />
910
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
1011
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Bootstrapper for the WASM cache service worker.
2+
// Registers the service worker on page load and exposes interop functions.
3+
4+
(function () {
5+
if (!('serviceWorker' in navigator)) {
6+
return;
7+
}
8+
9+
window.addEventListener('load', async function () {
10+
try {
11+
const registration = await navigator.serviceWorker.register('/wasm-cache-sw.js', {
12+
scope: '/'
13+
});
14+
console.log('[WasmCache] Service worker registered:', registration.scope);
15+
} catch (error) {
16+
console.error('[WasmCache] Service worker registration failed:', error);
17+
}
18+
});
19+
})();
20+
21+
/**
22+
* Clear the WASM cache. Returns true if successful.
23+
* @returns {Promise<boolean>}
24+
*/
25+
async function clearWasmCache() {
26+
const registration = await navigator.serviceWorker.ready;
27+
if (!registration.active) {
28+
// Fallback: clear directly if no active service worker
29+
return await caches.delete('blazor-wasm-cache-v1');
30+
}
31+
32+
return new Promise((resolve) => {
33+
const messageChannel = new MessageChannel();
34+
messageChannel.port1.onmessage = (event) => {
35+
resolve(event.data?.success ?? false);
36+
};
37+
registration.active.postMessage(
38+
{ type: 'CLEAR_WASM_CACHE' },
39+
[messageChannel.port2]
40+
);
41+
// Timeout after 5 seconds
42+
setTimeout(() => resolve(false), 5000);
43+
});
44+
}
45+
46+
/**
47+
* Get the status of the WASM cache.
48+
* @returns {Promise<{cacheName: string, entryCount: number} | null>}
49+
*/
50+
async function getWasmCacheStatus() {
51+
const registration = await navigator.serviceWorker.ready;
52+
if (!registration.active) {
53+
return null;
54+
}
55+
56+
return new Promise((resolve) => {
57+
const messageChannel = new MessageChannel();
58+
messageChannel.port1.onmessage = (event) => {
59+
resolve(event.data);
60+
};
61+
registration.active.postMessage(
62+
{ type: 'GET_CACHE_STATUS' },
63+
[messageChannel.port2]
64+
);
65+
// Timeout after 5 seconds
66+
setTimeout(() => resolve(null), 5000);
67+
});
68+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Service worker for caching Blazor WASM assets using stale-while-revalidate strategy.
2+
// Serves cached files immediately for fast startup, then revalidates in the background.
3+
// If files have changed, the cache is updated for the next load.
4+
5+
const CACHE_NAME = 'blazor-wasm-cache-v1';
6+
7+
// Asset patterns to cache with stale-while-revalidate
8+
const CACHE_PATTERNS = [
9+
/\/_framework\/.*\.wasm$/,
10+
/\/_framework\/.*\.dat$/,
11+
/\/_framework\/.*\.js$/,
12+
];
13+
14+
// Patterns that should always go to network first
15+
// NOTE: We intentionally do NOT put blazor.boot.json here.
16+
// If we serve a fresh boot.json but stale DLLs, Blazor's integrity checks will fail and crash the app.
17+
// By serving both from cache (stale-while-revalidate), we ensure the manifest matches the assemblies.
18+
// The app will update in the background and apply on the next reload.
19+
const NETWORK_FIRST_PATTERNS = [
20+
// Add any specific API endpoints or non-versioned assets here if needed
21+
];
22+
23+
self.addEventListener('install', (event) => {
24+
console.log('[WasmCache] Installing...');
25+
self.skipWaiting();
26+
});
27+
28+
self.addEventListener('activate', (event) => {
29+
console.log('[WasmCache] Activating...');
30+
event.waitUntil(
31+
Promise.all([
32+
// Clean up old cache versions
33+
caches.keys().then((cacheNames) => {
34+
return Promise.all(
35+
cacheNames
36+
.filter((name) => name.startsWith('blazor-wasm-cache-') && name !== CACHE_NAME)
37+
.map((name) => {
38+
console.log('[WasmCache] Deleting old cache:', name);
39+
return caches.delete(name);
40+
})
41+
);
42+
}),
43+
self.clients.claim()
44+
])
45+
);
46+
});
47+
48+
self.addEventListener('fetch', (event) => {
49+
const url = new URL(event.request.url);
50+
51+
if (event.request.method !== 'GET') {
52+
return;
53+
}
54+
55+
const shouldCache = CACHE_PATTERNS.some((pattern) => pattern.test(url.pathname));
56+
if (!shouldCache) {
57+
return;
58+
}
59+
60+
const isNetworkFirst = NETWORK_FIRST_PATTERNS.some((pattern) => pattern.test(url.pathname));
61+
62+
if (isNetworkFirst) {
63+
event.respondWith(networkFirstStrategy(event.request));
64+
} else {
65+
event.respondWith(staleWhileRevalidateStrategy(event.request));
66+
}
67+
});
68+
69+
/**
70+
* Stale-while-revalidate: Serve from cache immediately, revalidate in background.
71+
* If the resource has changed, update the cache for next time.
72+
*/
73+
async function staleWhileRevalidateStrategy(request) {
74+
const cache = await caches.open(CACHE_NAME);
75+
const cachedResponse = await cache.match(request);
76+
77+
// Start the network fetch in the background
78+
const networkFetchPromise = fetch(request).then(async (networkResponse) => {
79+
if (networkResponse.ok) {
80+
// Check if the response has actually changed before updating cache
81+
const shouldUpdate = await hasResponseChanged(cachedResponse, networkResponse.clone());
82+
if (shouldUpdate) {
83+
console.log('[WasmCache] Updating cache:', request.url);
84+
await cache.put(request, networkResponse.clone());
85+
}
86+
}
87+
return networkResponse;
88+
}).catch((error) => {
89+
console.warn('[WasmCache] Network fetch failed:', request.url, error);
90+
return null;
91+
});
92+
93+
if (cachedResponse) {
94+
// Serve from cache immediately, don't wait for network
95+
// Fire and forget the network request for background revalidation
96+
networkFetchPromise.catch(() => { });
97+
return cachedResponse;
98+
}
99+
100+
// No cache available, wait for network
101+
console.log('[WasmCache] Cache miss, fetching:', request.url);
102+
const networkResponse = await networkFetchPromise;
103+
if (networkResponse) {
104+
return networkResponse;
105+
}
106+
107+
// Both cache and network failed
108+
return new Response('Network error', { status: 503, statusText: 'Service Unavailable' });
109+
}
110+
111+
/**
112+
* Network-first strategy for critical files like boot.json.
113+
* Falls back to cache if network fails.
114+
*/
115+
async function networkFirstStrategy(request) {
116+
const cache = await caches.open(CACHE_NAME);
117+
118+
try {
119+
const networkResponse = await fetch(request);
120+
if (networkResponse.ok) {
121+
await cache.put(request, networkResponse.clone());
122+
}
123+
return networkResponse;
124+
} catch (error) {
125+
console.warn('[WasmCache] Network failed, trying cache:', request.url);
126+
const cachedResponse = await cache.match(request);
127+
if (cachedResponse) {
128+
return cachedResponse;
129+
}
130+
return new Response('Network error', { status: 503, statusText: 'Service Unavailable' });
131+
}
132+
}
133+
134+
/**
135+
* Compare responses to determine if cache should be updated.
136+
* Uses ETag or Last-Modified headers, or content length as fallback.
137+
*/
138+
async function hasResponseChanged(cachedResponse, networkResponse) {
139+
if (!cachedResponse) {
140+
return true;
141+
}
142+
143+
// Compare ETags
144+
const cachedEtag = cachedResponse.headers.get('ETag');
145+
const networkEtag = networkResponse.headers.get('ETag');
146+
if (cachedEtag && networkEtag) {
147+
return cachedEtag !== networkEtag;
148+
}
149+
150+
// Compare Last-Modified
151+
const cachedLastModified = cachedResponse.headers.get('Last-Modified');
152+
const networkLastModified = networkResponse.headers.get('Last-Modified');
153+
if (cachedLastModified && networkLastModified) {
154+
return cachedLastModified !== networkLastModified;
155+
}
156+
157+
// Compare Content-Length as fallback
158+
const cachedLength = cachedResponse.headers.get('Content-Length');
159+
const networkLength = networkResponse.headers.get('Content-Length');
160+
if (cachedLength && networkLength) {
161+
return cachedLength !== networkLength;
162+
}
163+
164+
// If we can't compare, assume it changed
165+
return true;
166+
}
167+
168+
// Message handlers for interop with the main thread
169+
self.addEventListener('message', async (event) => {
170+
if (!event.data || !event.data.type) {
171+
return;
172+
}
173+
174+
switch (event.data.type) {
175+
case 'CLEAR_WASM_CACHE': {
176+
const deleted = await caches.delete(CACHE_NAME);
177+
console.log('[WasmCache] Cache cleared:', deleted);
178+
if (event.ports && event.ports[0]) {
179+
event.ports[0].postMessage({ success: deleted });
180+
}
181+
break;
182+
}
183+
184+
case 'GET_CACHE_STATUS': {
185+
try {
186+
const cache = await caches.open(CACHE_NAME);
187+
const keys = await cache.keys();
188+
if (event.ports && event.ports[0]) {
189+
event.ports[0].postMessage({
190+
cacheName: CACHE_NAME,
191+
entryCount: keys.length
192+
});
193+
}
194+
} catch {
195+
if (event.ports && event.ports[0]) {
196+
event.ports[0].postMessage({ cacheName: CACHE_NAME, entryCount: 0 });
197+
}
198+
}
199+
break;
200+
}
201+
}
202+
});

0 commit comments

Comments
 (0)