mulmoclaude 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/client/assets/{JsonEditor-CU5Q6RZZ.js → JsonEditor-ZvNJuqs_.js} +1 -1
- package/client/assets/{index-hiEYYDcg.js → index-ijQN8Kty.js} +396 -396
- package/client/assets/{marp-WnOHAJUy.js → marp-DbrRRh1n.js} +1 -1
- package/client/assets/vue-i18n-BbRMUmOW.js +3 -0
- package/client/index.html +2 -2
- package/package.json +10 -10
- package/server/api/auth/bearerAuth.ts +10 -24
- package/server/api/auth/viewToken.ts +5 -12
- package/server/api/routes/files.ts +34 -17
- package/server/build/mcp-server.mjs +502 -505
- package/server/events/pub-sub/handshakeAuth.ts +9 -0
- package/server/events/pub-sub/index.ts +47 -14
- package/server/index.ts +1 -1
- package/server/plugins/dev-watcher.ts +1 -1
- package/server/utils/launcher/preflight.d.mts +8 -5
- package/server/utils/launcher/preflight.mjs +34 -8
- package/src/App.vue +5 -0
- package/src/components/AuthTokenStaleBanner.vue +27 -0
- package/src/components/BackendOfflineBanner.vue +13 -24
- package/src/components/RemoteHostOfflineBanner.vue +12 -21
- package/src/components/StatusBanner.vue +65 -0
- package/src/composables/usePubSub.ts +32 -0
- package/src/lang/de.ts +5 -0
- package/src/lang/en.ts +5 -0
- package/src/lang/es.ts +5 -0
- package/src/lang/fr.ts +5 -0
- package/src/lang/ja.ts +5 -0
- package/src/lang/ko.ts +5 -0
- package/src/lang/pt-BR.ts +5 -0
- package/src/lang/zh.ts +5 -0
- package/src/main.ts +1 -1
- package/src/utils/api.ts +45 -22
- package/src/utils/auth/authToken.ts +14 -0
- package/src/utils/auth/authTokenHtml.ts +19 -0
- package/src/utils/auth/authTokenRecovery.ts +73 -0
- package/src/utils/dom/authTokenMeta.ts +3 -1
- package/client/assets/vue-i18n-CdSW1CeF.js +0 -3
- package/server/system/logs/aaa +0 -737
- package/server/system/logs/bb +0 -446
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { isAuthorizedToken } from "@mulmoclaude/common/token-guard";
|
|
2
|
+
import { isRecord } from "../../utils/types.js";
|
|
3
|
+
|
|
4
|
+
// `auth` is whatever the socket.io client put in `io({ auth })`. Only a record
|
|
5
|
+
// whose `token` passes the shared bearer rule connects; a server with no token
|
|
6
|
+
// yet refuses everything, as `bearerAuth` does for HTTP.
|
|
7
|
+
export function isAuthorizedPubSubHandshake(auth: unknown, expectedToken: string | null): boolean {
|
|
8
|
+
return isRecord(auth) && isAuthorizedToken(auth.token, expectedToken);
|
|
9
|
+
}
|
|
@@ -1,26 +1,59 @@
|
|
|
1
1
|
import http from "http";
|
|
2
|
-
import { Server as IOServer } from "socket.io";
|
|
2
|
+
import { Server as IOServer, type Socket } from "socket.io";
|
|
3
3
|
|
|
4
|
+
import { UNAUTHORIZED_MESSAGE } from "@mulmoclaude/common/token-guard";
|
|
4
5
|
import { log } from "../../system/logger/index.js";
|
|
6
|
+
import { isAuthorizedPubSubHandshake } from "./handshakeAuth.js";
|
|
5
7
|
|
|
6
8
|
export interface IPubSub {
|
|
7
9
|
/** Publish data to all clients subscribed to this channel. */
|
|
8
10
|
publish: (channel: string, data: unknown) => void;
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
export interface PubSubOptions {
|
|
14
|
+
/** The bearer token a handshake must present. `null` while the server
|
|
15
|
+
* has none yet, which refuses every handshake. */
|
|
16
|
+
tokenProvider: () => string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
const logRoomError =
|
|
12
20
|
(action: string, channel: string) =>
|
|
13
21
|
(err: unknown): void => {
|
|
14
22
|
log.warn("pubsub", `socket room ${action} failed`, { channel, error: String(err) });
|
|
15
23
|
};
|
|
16
24
|
|
|
25
|
+
// `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
|
|
26
|
+
// synchronously and returns undefined, while a clustered adapter goes over
|
|
27
|
+
// the wire. Nothing here depends on the result, but the promise branch
|
|
28
|
+
// still needs a terminal handler — a bare `void` would turn an adapter
|
|
29
|
+
// failure into an unhandled rejection. `Promise.resolve` normalises both.
|
|
30
|
+
function bindRoomEvents(socket: Socket): void {
|
|
31
|
+
socket.on("subscribe", (channel: unknown) => {
|
|
32
|
+
if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
|
|
33
|
+
});
|
|
34
|
+
socket.on("unsubscribe", (channel: unknown) => {
|
|
35
|
+
if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A refused handshake leaves the engine.io connection open, and that one
|
|
40
|
+
// connection can keep sending CONNECT packets; one line per connection keeps
|
|
41
|
+
// a misconfigured relay diagnosable without letting a loop fill the log.
|
|
42
|
+
const warnedConnections = new WeakSet<object>();
|
|
43
|
+
|
|
44
|
+
function warnRefusedOnce(socket: Socket): void {
|
|
45
|
+
if (warnedConnections.has(socket.conn)) return;
|
|
46
|
+
warnedConnections.add(socket.conn);
|
|
47
|
+
log.warn("pubsub", "handshake refused: bearer token missing or invalid", { address: socket.handshake.address });
|
|
48
|
+
}
|
|
49
|
+
|
|
17
50
|
// Channel names are treated as socket.io rooms — one room per
|
|
18
51
|
// channel. Subscribe/unsubscribe is plain `socket.join` /
|
|
19
52
|
// `socket.leave`. Publish broadcasts to the room. Reconnect /
|
|
20
53
|
// heartbeat / multi-transport fallback are handled by socket.io
|
|
21
54
|
// itself, which is why we switched off raw ws.
|
|
22
55
|
|
|
23
|
-
export function createPubSub(server: http.Server): IPubSub {
|
|
56
|
+
export function createPubSub(server: http.Server, options: PubSubOptions): IPubSub {
|
|
24
57
|
const ioServer = new IOServer(server, {
|
|
25
58
|
path: "/ws/pubsub",
|
|
26
59
|
// Server binds to 127.0.0.1 only, so CORS is moot — but
|
|
@@ -36,20 +69,20 @@ export function createPubSub(server: http.Server): IPubSub {
|
|
|
36
69
|
transports: ["websocket"],
|
|
37
70
|
});
|
|
38
71
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
|
|
50
|
-
});
|
|
72
|
+
// Same token every `/api/*` call presents (#3433). A refused handshake
|
|
73
|
+
// destroys the client socket, so socket.io does not retry it — the SPA
|
|
74
|
+
// reads that as "reload for a fresh token" rather than spinning.
|
|
75
|
+
ioServer.use((socket, next) => {
|
|
76
|
+
if (isAuthorizedPubSubHandshake(socket.handshake.auth, options.tokenProvider())) {
|
|
77
|
+
next();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
warnRefusedOnce(socket);
|
|
81
|
+
next(new Error(UNAUTHORIZED_MESSAGE));
|
|
51
82
|
});
|
|
52
83
|
|
|
84
|
+
ioServer.on("connection", bindRoomEvents);
|
|
85
|
+
|
|
53
86
|
return {
|
|
54
87
|
publish(channel: string, data: unknown): void {
|
|
55
88
|
ioServer.to(channel).emit("data", { channel, data });
|
package/server/index.ts
CHANGED
|
@@ -1548,7 +1548,7 @@ process.on("SIGTERM", () => {
|
|
|
1548
1548
|
// (CodeRabbit review on PR #1196). Both `createPubSub` and
|
|
1549
1549
|
// `initNotifier` are sync, so wiring them up here costs nothing
|
|
1550
1550
|
// and closes the window.
|
|
1551
|
-
const earlyPubsub = createPubSub(httpServer);
|
|
1551
|
+
const earlyPubsub = createPubSub(httpServer, { tokenProvider: getCurrentToken });
|
|
1552
1552
|
initNotifier({
|
|
1553
1553
|
publish: (channel, payload) => earlyPubsub.publish(channel, payload),
|
|
1554
1554
|
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// chokidar to avoid pulling another runtime dependency. Recursive
|
|
7
7
|
// watching is reliable on macOS / Linux / Windows starting from Node
|
|
8
8
|
// 20.12 (Linux had race conditions and crash-on-delete bugs in earlier
|
|
9
|
-
// 20.x patches), which engines.node
|
|
9
|
+
// 20.x patches), which engines.node clears comfortably.
|
|
10
10
|
//
|
|
11
11
|
// The publish + warnServerSideChange callbacks are injected so tests
|
|
12
12
|
// can exercise the debounce + classification logic without booting the
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
// Type declarations for preflight.mjs.
|
|
2
2
|
|
|
3
|
-
export interface
|
|
3
|
+
export interface NodeVersion {
|
|
4
4
|
major: number;
|
|
5
5
|
minor: number;
|
|
6
|
+
patch: number;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
export const REQUIRED_NODE:
|
|
9
|
+
export const REQUIRED_NODE: readonly NodeVersion[];
|
|
9
10
|
|
|
10
|
-
export function parseNodeVersion(raw: string):
|
|
11
|
+
export function parseNodeVersion(raw: string): NodeVersion | null;
|
|
11
12
|
|
|
12
|
-
export function isNodeVersionSupported(raw: string, required?:
|
|
13
|
+
export function isNodeVersionSupported(raw: string, required?: readonly NodeVersion[]): boolean;
|
|
13
14
|
|
|
14
|
-
export function formatRequiredNode(required?:
|
|
15
|
+
export function formatRequiredNode(required?: readonly NodeVersion[]): string;
|
|
16
|
+
|
|
17
|
+
export function formatEnginesRange(required?: readonly NodeVersion[]): string;
|
|
15
18
|
|
|
16
19
|
export interface CommandProbeOptions {
|
|
17
20
|
env?: Record<string, string | undefined>;
|
|
@@ -9,7 +9,13 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
|
|
10
10
|
// Mirrors `engines.node` in packages/mulmoclaude/package.json.
|
|
11
11
|
// test/utils/launcher/test_preflight.ts fails if the two drift.
|
|
12
|
-
|
|
12
|
+
// One floor per supported major; the last also admits every later major,
|
|
13
|
+
// and a major with no floor of its own (23, 25) is unsupported.
|
|
14
|
+
export const REQUIRED_NODE = [
|
|
15
|
+
{ major: 22, minor: 22, patch: 2 },
|
|
16
|
+
{ major: 24, minor: 15, patch: 0 },
|
|
17
|
+
{ major: 26, minor: 0, patch: 0 },
|
|
18
|
+
];
|
|
13
19
|
|
|
14
20
|
// These probes run a local binary that reads its own package metadata;
|
|
15
21
|
// anything past a few seconds means it is wedged, not slow.
|
|
@@ -22,30 +28,50 @@ const COMMAND_PROBE_TIMEOUT_MS = 5000;
|
|
|
22
28
|
* Returns null for anything unparseable rather than guessing — an
|
|
23
29
|
* unknown version must not silently pass the requirement.
|
|
24
30
|
* @param {string} raw
|
|
25
|
-
* @returns {
|
|
31
|
+
* @returns {import("./preflight.d.mts").NodeVersion | null}
|
|
26
32
|
*/
|
|
27
33
|
export function parseNodeVersion(raw) {
|
|
28
34
|
if (typeof raw !== "string") return null;
|
|
29
|
-
const match = /^v?(\d+)\.(\d+)
|
|
35
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
|
|
30
36
|
if (!match) return null;
|
|
31
|
-
return { major: Number(match[1]), minor: Number(match[2]) };
|
|
37
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function compareVersions(left, right) {
|
|
41
|
+
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function floorFor(found, required) {
|
|
45
|
+
const newest = required[required.length - 1];
|
|
46
|
+
if (found.major > newest.major) return newest;
|
|
47
|
+
return required.find((floor) => floor.major === found.major);
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
/**
|
|
35
51
|
* @param {string} raw
|
|
36
|
-
* @param {
|
|
52
|
+
* @param {readonly import("./preflight.d.mts").NodeVersion[]} [required]
|
|
37
53
|
* @returns {boolean}
|
|
38
54
|
*/
|
|
39
55
|
export function isNodeVersionSupported(raw, required = REQUIRED_NODE) {
|
|
40
56
|
const found = parseNodeVersion(raw);
|
|
41
57
|
if (found === null) return false;
|
|
42
|
-
|
|
43
|
-
return found
|
|
58
|
+
const floor = floorFor(found, required);
|
|
59
|
+
return floor !== undefined && compareVersions(found, floor) >= 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatVersion({ major, minor, patch }) {
|
|
63
|
+
return `${major}.${minor}.${patch}`;
|
|
44
64
|
}
|
|
45
65
|
|
|
46
66
|
/** Human-readable form of a requirement, for the {required} placeholder. */
|
|
47
67
|
export function formatRequiredNode(required = REQUIRED_NODE) {
|
|
48
|
-
return
|
|
68
|
+
return required.map(formatVersion).join(" / ");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The same requirement as an `engines.node` range. */
|
|
72
|
+
export function formatEnginesRange(required = REQUIRED_NODE) {
|
|
73
|
+
const carets = required.slice(0, -1).map((floor) => `^${formatVersion(floor)}`);
|
|
74
|
+
return [...carets, `>=${formatVersion(required[required.length - 1])}`].join(" || ");
|
|
49
75
|
}
|
|
50
76
|
|
|
51
77
|
/**
|
package/src/App.vue
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
it's the first thing the user sees when the server isn't
|
|
5
5
|
reachable. Self-hiding when fetchHealth succeeds. -->
|
|
6
6
|
<BackendOfflineBanner :on-retry="fetchHealth" />
|
|
7
|
+
<!-- Stale-token notice (#3438) — the server refused this page's token and
|
|
8
|
+
fetching index.html again did not produce one it accepts. Recovery is
|
|
9
|
+
the normal path after a restart; this is its last resort. -->
|
|
10
|
+
<AuthTokenStaleBanner />
|
|
7
11
|
<!-- Remote-host disconnect notice (#2535) — shown when the host was meant to
|
|
8
12
|
be connected (a session is parked) but dropped and a silent reconnect
|
|
9
13
|
couldn't restore it. Offers a one-click re-login. -->
|
|
@@ -361,6 +365,7 @@ import { ref, computed, watch, nextTick, onMounted, onScopeDispose, reactive } f
|
|
|
361
365
|
import { useI18n } from "vue-i18n";
|
|
362
366
|
import { getPlugin } from "./tools";
|
|
363
367
|
import type { ToolResultComplete } from "gui-chat-protocol/vue";
|
|
368
|
+
import AuthTokenStaleBanner from "./components/AuthTokenStaleBanner.vue";
|
|
364
369
|
import BackendOfflineBanner from "./components/BackendOfflineBanner.vue";
|
|
365
370
|
import RemoteHostOfflineBanner from "./components/RemoteHostOfflineBanner.vue";
|
|
366
371
|
import RightSidebar from "./components/RightSidebar.vue";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<StatusBanner
|
|
3
|
+
v-if="authTokenStale"
|
|
4
|
+
tone="warning"
|
|
5
|
+
icon="sync_disabled"
|
|
6
|
+
testid="auth-token-stale-banner"
|
|
7
|
+
action-testid="auth-token-stale-reload"
|
|
8
|
+
:title="t('authTokenStale.title')"
|
|
9
|
+
:body="t('authTokenStale.body')"
|
|
10
|
+
:action-label="t('authTokenStale.reload')"
|
|
11
|
+
@action="reload"
|
|
12
|
+
/>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
// Shown once the server refused this page's token and fetching index.html
|
|
17
|
+
// again did not produce one it accepts (#3438). Auto-recovery is the normal
|
|
18
|
+
// path after a server restart; this is its last resort, and a reload is the
|
|
19
|
+
// only thing left that fetches a fresh token.
|
|
20
|
+
import { useI18n } from "vue-i18n";
|
|
21
|
+
import StatusBanner from "./StatusBanner.vue";
|
|
22
|
+
import { authTokenStale } from "../utils/auth/authTokenRecovery";
|
|
23
|
+
|
|
24
|
+
const { t } = useI18n();
|
|
25
|
+
|
|
26
|
+
const reload = (): void => window.location.reload();
|
|
27
|
+
</script>
|
|
@@ -1,34 +1,23 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<StatusBanner
|
|
3
3
|
v-if="!backendReachable"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
</div>
|
|
16
|
-
<button
|
|
17
|
-
type="button"
|
|
18
|
-
data-testid="backend-offline-retry"
|
|
19
|
-
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-red-300 text-red-700 hover:bg-red-100 disabled:opacity-40"
|
|
20
|
-
:disabled="retrying"
|
|
21
|
-
@click="retry"
|
|
22
|
-
>
|
|
23
|
-
<span class="material-icons text-sm" :class="retrying ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
|
|
24
|
-
{{ t("backendOffline.retry") }}
|
|
25
|
-
</button>
|
|
26
|
-
</div>
|
|
4
|
+
tone="error"
|
|
5
|
+
icon="cloud_off"
|
|
6
|
+
testid="backend-offline-banner"
|
|
7
|
+
action-testid="backend-offline-retry"
|
|
8
|
+
:title="t('backendOffline.title')"
|
|
9
|
+
:body="t('backendOffline.body')"
|
|
10
|
+
:detail="lastBackendError"
|
|
11
|
+
:action-label="t('backendOffline.retry')"
|
|
12
|
+
:busy="retrying"
|
|
13
|
+
@action="retry"
|
|
14
|
+
/>
|
|
27
15
|
</template>
|
|
28
16
|
|
|
29
17
|
<script setup lang="ts">
|
|
30
18
|
import { ref } from "vue";
|
|
31
19
|
import { useI18n } from "vue-i18n";
|
|
20
|
+
import StatusBanner from "./StatusBanner.vue";
|
|
32
21
|
import { backendReachable, lastBackendError } from "../utils/api";
|
|
33
22
|
|
|
34
23
|
const { t } = useI18n();
|
|
@@ -1,26 +1,16 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<StatusBanner
|
|
3
3
|
v-if="needsReconnect"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type="button"
|
|
15
|
-
data-testid="remote-host-offline-reconnect"
|
|
16
|
-
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-amber-300 text-amber-800 hover:bg-amber-100 disabled:opacity-40"
|
|
17
|
-
:disabled="busy"
|
|
18
|
-
@click="reconnect"
|
|
19
|
-
>
|
|
20
|
-
<span class="material-icons text-sm" :class="busy ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
|
|
21
|
-
{{ t("remoteHostOffline.reconnect") }}
|
|
22
|
-
</button>
|
|
23
|
-
</div>
|
|
4
|
+
tone="warning"
|
|
5
|
+
icon="phonelink_off"
|
|
6
|
+
testid="remote-host-offline-banner"
|
|
7
|
+
action-testid="remote-host-offline-reconnect"
|
|
8
|
+
:title="t('remoteHostOffline.title')"
|
|
9
|
+
:body="t('remoteHostOffline.body')"
|
|
10
|
+
:action-label="t('remoteHostOffline.reconnect')"
|
|
11
|
+
:busy="busy"
|
|
12
|
+
@action="reconnect"
|
|
13
|
+
/>
|
|
24
14
|
</template>
|
|
25
15
|
|
|
26
16
|
<script setup lang="ts">
|
|
@@ -29,6 +19,7 @@
|
|
|
29
19
|
// session expired). Reads the shared `useRemoteHost` store; Reconnect re-runs the
|
|
30
20
|
// Google sign-in popup from this click (a user gesture, so the popup isn't blocked).
|
|
31
21
|
import { useI18n } from "vue-i18n";
|
|
22
|
+
import StatusBanner from "./StatusBanner.vue";
|
|
32
23
|
import { useRemoteHost } from "../composables/useRemoteHost";
|
|
33
24
|
|
|
34
25
|
const { t } = useI18n();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :data-testid="testid" role="alert" class="flex items-center gap-2 px-3 py-2 border-b text-sm" :class="palette.root">
|
|
3
|
+
<span class="material-icons text-base" aria-hidden="true">{{ icon }}</span>
|
|
4
|
+
<div class="flex-1 min-w-0">
|
|
5
|
+
<div class="font-medium">{{ title }}</div>
|
|
6
|
+
<div class="text-xs truncate" :class="palette.body">
|
|
7
|
+
<span>{{ body }}</span>
|
|
8
|
+
<span v-if="detail" class="opacity-70">{{ ` — ${detail}` }}</span>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
:data-testid="actionTestid"
|
|
14
|
+
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border disabled:opacity-40"
|
|
15
|
+
:class="palette.button"
|
|
16
|
+
:disabled="busy"
|
|
17
|
+
@click="emit('action')"
|
|
18
|
+
>
|
|
19
|
+
<span class="material-icons text-sm" :class="busy ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
|
|
20
|
+
{{ actionLabel }}
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
// The one chrome for a page-wide status notice pinned above the top bar: an
|
|
27
|
+
// icon, a title, one line of body with optional small print, and one action.
|
|
28
|
+
// The wrapper that mounts it owns the signal (`v-if`) and the action.
|
|
29
|
+
import { computed } from "vue";
|
|
30
|
+
|
|
31
|
+
const PALETTES = {
|
|
32
|
+
error: {
|
|
33
|
+
root: "bg-red-50 border-red-200 text-red-700",
|
|
34
|
+
body: "text-red-600",
|
|
35
|
+
button: "border-red-300 text-red-700 hover:bg-red-100",
|
|
36
|
+
},
|
|
37
|
+
warning: {
|
|
38
|
+
root: "bg-amber-50 border-amber-200 text-amber-800",
|
|
39
|
+
body: "text-amber-700",
|
|
40
|
+
button: "border-amber-300 text-amber-800 hover:bg-amber-100",
|
|
41
|
+
},
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const props = withDefaults(
|
|
45
|
+
defineProps<{
|
|
46
|
+
tone: keyof typeof PALETTES;
|
|
47
|
+
/** A Material Icons name. */
|
|
48
|
+
icon: string;
|
|
49
|
+
title: string;
|
|
50
|
+
body: string;
|
|
51
|
+
/** Small print after the body, rendered dimmer. */
|
|
52
|
+
detail?: string | null;
|
|
53
|
+
actionLabel: string;
|
|
54
|
+
/** Disables the action and spins its icon. */
|
|
55
|
+
busy?: boolean;
|
|
56
|
+
testid: string;
|
|
57
|
+
actionTestid: string;
|
|
58
|
+
}>(),
|
|
59
|
+
{ detail: null, busy: false },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const emit = defineEmits<{ action: [] }>();
|
|
63
|
+
|
|
64
|
+
const palette = computed(() => PALETTES[props.tone]);
|
|
65
|
+
</script>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { io, type Socket } from "socket.io-client";
|
|
2
|
+
import { getAuthToken } from "../utils/auth/authToken";
|
|
3
|
+
import { authTokenStale, recoverAuthToken } from "../utils/auth/authTokenRecovery";
|
|
2
4
|
|
|
3
5
|
interface PubSubMessage {
|
|
4
6
|
channel: string;
|
|
@@ -35,6 +37,23 @@ function fireReconnectHandlers(): void {
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** The server refused the handshake. The token is regenerated at every server
|
|
41
|
+
* start, so this is what a restart looks like from a page that stayed open;
|
|
42
|
+
* index.html carries the new token, and `auth` re-reads it on the next
|
|
43
|
+
* CONNECT. socket.io does not retry a refusal on its own (#3433), so the
|
|
44
|
+
* reconnect is explicit — and only with a token the server has not refused
|
|
45
|
+
* yet, so a second refusal ends with the banner rather than a loop (#3438). */
|
|
46
|
+
async function reconnectWithFreshToken(refusedSocket: Socket): Promise<void> {
|
|
47
|
+
const recovery = await recoverAuthToken();
|
|
48
|
+
// `maybeDisconnect` may have let go of this socket while index.html was fetched.
|
|
49
|
+
if (socket !== refusedSocket) return;
|
|
50
|
+
if (recovery === "installed") {
|
|
51
|
+
refusedSocket.connect();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
authTokenStale.value = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
function connect(): Socket {
|
|
39
58
|
if (socket) return socket;
|
|
40
59
|
|
|
@@ -42,9 +61,13 @@ function connect(): Socket {
|
|
|
42
61
|
path: "/ws/pubsub",
|
|
43
62
|
// Server refuses long-polling fallback, so fail fast here too if the WS upgrade doesn't go through.
|
|
44
63
|
transports: ["websocket"],
|
|
64
|
+
// The same bearer token every `apiCall` attaches (#3433). A function, so
|
|
65
|
+
// each CONNECT re-reads it instead of re-sending the first value.
|
|
66
|
+
auth: (provide) => provide({ token: getAuthToken() }),
|
|
45
67
|
});
|
|
46
68
|
|
|
47
69
|
sock.on("connect", () => {
|
|
70
|
+
authTokenStale.value = false;
|
|
48
71
|
resendSubscriptions(sock);
|
|
49
72
|
if (hasConnectedOnce) {
|
|
50
73
|
fireReconnectHandlers();
|
|
@@ -53,6 +76,15 @@ function connect(): Socket {
|
|
|
53
76
|
}
|
|
54
77
|
});
|
|
55
78
|
|
|
79
|
+
sock.on("connect_error", (err: Error) => {
|
|
80
|
+
// A transport failure leaves `active` true and socket.io retries it on its
|
|
81
|
+
// own. A refusal from the server middleware destroys the socket (`active`
|
|
82
|
+
// false) and nothing else will retry it.
|
|
83
|
+
if (sock.active) return;
|
|
84
|
+
console.error("[usePubSub] handshake refused by the server:", err.message);
|
|
85
|
+
void reconnectWithFreshToken(sock);
|
|
86
|
+
});
|
|
87
|
+
|
|
56
88
|
sock.on("data", (msg: PubSubMessage) => {
|
|
57
89
|
const cbs = listeners.get(msg.channel);
|
|
58
90
|
if (cbs) {
|
package/src/lang/de.ts
CHANGED
|
@@ -139,6 +139,11 @@ const deMessages = {
|
|
|
139
139
|
body: "Ihr Telefon kann erst nach dem erneuten Verbinden an dieses Gerät senden.",
|
|
140
140
|
reconnect: "Erneut verbinden",
|
|
141
141
|
},
|
|
142
|
+
authTokenStale: {
|
|
143
|
+
title: "Diese Seite muss neu geladen werden",
|
|
144
|
+
body: "Der Server hat das Token dieser Seite abgelehnt, und ein neues konnte nicht abgerufen werden. Laden Sie die Seite neu, um fortzufahren.",
|
|
145
|
+
reload: "Neu laden",
|
|
146
|
+
},
|
|
142
147
|
remoteHost: {
|
|
143
148
|
title: "Remote-Host",
|
|
144
149
|
online: "Remote-Host online",
|
package/src/lang/en.ts
CHANGED
|
@@ -157,6 +157,11 @@ const enMessages = {
|
|
|
157
157
|
body: "Your phone can't send to this device until you reconnect.",
|
|
158
158
|
reconnect: "Reconnect",
|
|
159
159
|
},
|
|
160
|
+
authTokenStale: {
|
|
161
|
+
title: "This page needs a reload",
|
|
162
|
+
body: "The server refused this page's token and a new one could not be fetched. Reload to continue.",
|
|
163
|
+
reload: "Reload",
|
|
164
|
+
},
|
|
160
165
|
remoteHost: {
|
|
161
166
|
title: "Remote host",
|
|
162
167
|
online: "Remote host online",
|
package/src/lang/es.ts
CHANGED
|
@@ -142,6 +142,11 @@ const esMessages = {
|
|
|
142
142
|
body: "Tu teléfono no podrá enviar a este dispositivo hasta que vuelvas a conectarte.",
|
|
143
143
|
reconnect: "Reconectar",
|
|
144
144
|
},
|
|
145
|
+
authTokenStale: {
|
|
146
|
+
title: "Esta página necesita recargarse",
|
|
147
|
+
body: "El servidor rechazó el token de esta página y no se pudo obtener uno nuevo. Recarga para continuar.",
|
|
148
|
+
reload: "Recargar",
|
|
149
|
+
},
|
|
145
150
|
remoteHost: {
|
|
146
151
|
title: "Host remoto",
|
|
147
152
|
online: "Host remoto en línea",
|
package/src/lang/fr.ts
CHANGED
|
@@ -137,6 +137,11 @@ const frMessages = {
|
|
|
137
137
|
body: "Votre téléphone ne pourra pas envoyer vers cet appareil tant que vous n'êtes pas reconnecté.",
|
|
138
138
|
reconnect: "Se reconnecter",
|
|
139
139
|
},
|
|
140
|
+
authTokenStale: {
|
|
141
|
+
title: "Cette page doit être rechargée",
|
|
142
|
+
body: "Le serveur a refusé le jeton de cette page et un nouveau n'a pas pu être récupéré. Rechargez pour continuer.",
|
|
143
|
+
reload: "Recharger",
|
|
144
|
+
},
|
|
140
145
|
remoteHost: {
|
|
141
146
|
title: "Hôte distant",
|
|
142
147
|
online: "Hôte distant en ligne",
|
package/src/lang/ja.ts
CHANGED
|
@@ -142,6 +142,11 @@ const jaMessages = {
|
|
|
142
142
|
body: "再接続するまで、スマホからの送信はこの端末に届きません。",
|
|
143
143
|
reconnect: "再接続",
|
|
144
144
|
},
|
|
145
|
+
authTokenStale: {
|
|
146
|
+
title: "ページの再読み込みが必要です",
|
|
147
|
+
body: "サーバーがこのページのトークンを拒否し、新しいトークンを取得できませんでした。続けるには再読み込みしてください。",
|
|
148
|
+
reload: "再読み込み",
|
|
149
|
+
},
|
|
145
150
|
remoteHost: {
|
|
146
151
|
title: "リモートホスト",
|
|
147
152
|
online: "リモートホスト: オンライン",
|
package/src/lang/ko.ts
CHANGED
|
@@ -143,6 +143,11 @@ const koMessages = {
|
|
|
143
143
|
body: "다시 연결하기 전에는 휴대폰에서 이 기기로 보낼 수 없습니다.",
|
|
144
144
|
reconnect: "다시 연결",
|
|
145
145
|
},
|
|
146
|
+
authTokenStale: {
|
|
147
|
+
title: "페이지를 새로 고쳐야 합니다",
|
|
148
|
+
body: "서버가 이 페이지의 토큰을 거부했고 새 토큰을 가져오지 못했습니다. 계속하려면 새로 고침하세요.",
|
|
149
|
+
reload: "새로 고침",
|
|
150
|
+
},
|
|
146
151
|
remoteHost: {
|
|
147
152
|
title: "원격 호스트",
|
|
148
153
|
online: "원격 호스트 온라인",
|
package/src/lang/pt-BR.ts
CHANGED
|
@@ -137,6 +137,11 @@ const ptBRMessages = {
|
|
|
137
137
|
body: "Seu telefone não conseguirá enviar para este dispositivo até você reconectar.",
|
|
138
138
|
reconnect: "Reconectar",
|
|
139
139
|
},
|
|
140
|
+
authTokenStale: {
|
|
141
|
+
title: "Esta página precisa ser recarregada",
|
|
142
|
+
body: "O servidor recusou o token desta página e não foi possível obter um novo. Recarregue para continuar.",
|
|
143
|
+
reload: "Recarregar",
|
|
144
|
+
},
|
|
140
145
|
remoteHost: {
|
|
141
146
|
title: "Host remoto",
|
|
142
147
|
online: "Host remoto on-line",
|
package/src/lang/zh.ts
CHANGED
|
@@ -138,6 +138,11 @@ const zhMessages = {
|
|
|
138
138
|
body: "重新连接之前,手机将无法发送到此设备。",
|
|
139
139
|
reconnect: "重新连接",
|
|
140
140
|
},
|
|
141
|
+
authTokenStale: {
|
|
142
|
+
title: "此页面需要重新加载",
|
|
143
|
+
body: "服务器拒绝了此页面的令牌,且无法获取新令牌。请重新加载以继续。",
|
|
144
|
+
reload: "重新加载",
|
|
145
|
+
},
|
|
141
146
|
remoteHost: {
|
|
142
147
|
title: "远程主机",
|
|
143
148
|
online: "远程主机在线",
|
package/src/main.ts
CHANGED
|
@@ -9,7 +9,7 @@ import App from "./App.vue";
|
|
|
9
9
|
import router from "./router/index";
|
|
10
10
|
import { installGuards } from "./router/guards";
|
|
11
11
|
import i18n from "./lib/vue-i18n";
|
|
12
|
-
import { setAuthToken } from "./utils/
|
|
12
|
+
import { setAuthToken } from "./utils/auth/authToken";
|
|
13
13
|
import { readAuthTokenFromMeta } from "./utils/dom/authTokenMeta";
|
|
14
14
|
import { loadRuntimePlugins } from "./tools/runtimeLoader";
|
|
15
15
|
import { startDevPluginReloadListener } from "./composables/useDevPluginReload";
|