diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 0a5d5df6f5..7956835a05 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -129,6 +129,14 @@ Files must be uploaded after enabling these settings. See See [Slicer Uploads](/features/slicer-uploads) for setup instructions covering OrcaSlicer, PrusaSlicer, SuperSlicer, and Cura. +### My Spoolman spool weight stopped updating after a Spoolman upgrade + +Spoolman 0.26 added browser origin checks that block the direct connection +Fluidd uses for live spool updates, even though spool selection and everything +else still work. Add Fluidd's address to `SPOOLMAN_CORS_ORIGIN` on your +Spoolman host and restart it. See +[Live updates](/features/multi-material#live-updates) for details. + ## System ### The host reboot / shutdown commands don't work diff --git a/docs/docs/features/multi-material.md b/docs/docs/features/multi-material.md index 0e1521a1ab..8dd1568856 100644 --- a/docs/docs/features/multi-material.md +++ b/docs/docs/features/multi-material.md @@ -26,6 +26,36 @@ Pressure Advance values. Fluidd integrates with [Spoolman](https://github.com/Donkie/Spoolman) for filament spool tracking. +### Live updates + +Fluidd connects to Spoolman in two different ways: through Moonraker, for +loading spool data, and directly from your browser, for live updates (spool +weight, filament, and vendor changes). Only the direct browser connection is +subject to your browser's cross-origin rules. + +Spoolman 0.26 and newer reject that direct connection unless Fluidd's origin +is explicitly allowed, which breaks live updates while everything else +(spool selection, sanity checks) keeps working normally. If your spool +weight stops updating during a print after upgrading Spoolman, add the +`SPOOLMAN_CORS_ORIGIN` environment variable to your Spoolman host and +restart it: + +```text title=".env" +SPOOLMAN_CORS_ORIGIN=http://fluidd.local,http://192.168.1.50 +``` + +Each entry is the scheme, host, and port shown in your browser's address bar +when you access Fluidd (no path), separated by commas if you use more than +one address. + +!!! warning "Avoid the wildcard" + `SPOOLMAN_CORS_ORIGIN=*` allows every origin and turns off Spoolman's + origin checks entirely. Only use it on a trusted network. + +See the +[`.env.example` file](https://github.com/Donkie/Spoolman/blob/master/.env.example) +in the Spoolman repository for more examples. + ### Print start On print start, Fluidd shows a modal asking you to select a spool. You can diff --git a/docs/docs/features/third-party-integrations.md b/docs/docs/features/third-party-integrations.md index d1bd2e7f04..e3d2887a17 100644 --- a/docs/docs/features/third-party-integrations.md +++ b/docs/docs/features/third-party-integrations.md @@ -123,6 +123,12 @@ Filament spool tracking with QR code scanning, toolchanger support, and print-start sanity checks. See [Spool Management](/features/multi-material#spool-management-spoolman) for details. +!!! warning "Spoolman 0.26+ requires a CORS configuration change" + Fluidd connects to Spoolman directly from your browser for live spool + updates. Spoolman 0.26 and newer block that connection unless you add + Fluidd's address to `SPOOLMAN_CORS_ORIGIN`. See + [Live updates](/features/multi-material#live-updates) for details. + [Spoolman on GitHub](https://github.com/Donkie/Spoolman){.md-button} ### Timelapse diff --git a/src/components/widgets/spoolman/SpoolmanCard.vue b/src/components/widgets/spoolman/SpoolmanCard.vue index 7dedc0b823..502ce95312 100644 --- a/src/components/widgets/spoolman/SpoolmanCard.vue +++ b/src/components/widgets/spoolman/SpoolmanCard.vue @@ -99,74 +99,67 @@ /> - - - - - {{ $t('app.spoolman.msg.tracking_inactive') }} - + + {{ socketDiagnosticMessage }} + + - {{ $t('app.spoolman.msg.not_connected') }} + - - $progressQuestion - - + + + + {{ $t('app.spoolman.msg.tracking_inactive') }} + + + + + - $warning - + {{ $t('app.spoolman.msg.not_connected') }} + @@ -265,6 +267,29 @@ export default class SpoolmanCard extends Mixins(StateMixin) { return this.$typedState.spoolman.connected } + get socketDiagnosticMessage (): string | null { + const diagnostic = this.$typedState.spoolman.socketDiagnostic + + if (!this.isConnected || !diagnostic) return null + + switch (diagnostic) { + case 'mixed-content': + return this.$t('app.spoolman.msg.live_updates.mixed_content').toString() + + case 'cors': + return this.$t('app.spoolman.msg.live_updates.cors').toString() + + case 'unreachable': + return this.$t('app.spoolman.msg.live_updates.unreachable').toString() + + case 'reachable': + return this.$t('app.spoolman.msg.live_updates.failed').toString() + + case 'cancelled': + return null + } + } + get targetableMacros () { const macros: Macro[] = this.$typedGetters['macros/getMacros'] diff --git a/src/locales/en.yaml b/src/locales/en.yaml index 30d308eda6..c30e6abd09 100644 --- a/src/locales/en.yaml +++ b/src/locales/en.yaml @@ -1168,6 +1168,21 @@ app: Filament tracking is inactive. To get started, please select a spool. not_connected: Spoolman server not available. + live_updates: + cors: >- + Live updates from Spoolman aren't working. Spoolman is blocking + Fluidd's browser origin - check your Spoolman configuration to + allow it. + mixed_content: >- + Live updates from Spoolman aren't working. Fluidd is served over + HTTPS but the Spoolman address uses HTTP, so the browser blocks the + direct connection. + unreachable: >- + Live updates from Spoolman aren't working. Fluidd's browser can't + reach the Spoolman server directly. + failed: >- + Live updates from Spoolman aren't working. The direct connection to + Spoolman failed for an unknown reason. info: howto: >- Show your spool's QR code to the camera. diff --git a/src/store/spoolman/actions.ts b/src/store/spoolman/actions.ts index 65159f973a..38f1c2abeb 100644 --- a/src/store/spoolman/actions.ts +++ b/src/store/spoolman/actions.ts @@ -11,6 +11,7 @@ import { SocketActions } from '@/api/socketActions' import { consola } from 'consola' import { EventBus } from '@/eventBus' import { gte, valid } from 'semver' +import diagnoseHttpEndpoint from '@/util/http-endpoint-diagnostics' const logPrefix = '[SPOOLMAN]' @@ -49,6 +50,8 @@ const createSpoolmanSocket = (spoolmanUrl: string): WebSocket | undefined => { } } +let diagnoseSocketFailureAbortController: AbortController | undefined + export const actions = { /** * Reset our store @@ -209,10 +212,26 @@ export const actions = { if (socket == null) { commit('setSocket', null) + commit('setSocketDiagnostic', 'unreachable') return } + let opened = false + + socket.onopen = () => { + opened = true + diagnoseSocketFailureAbortController?.abort() + commit('setSocketDiagnostic', null) + } + socket.onerror = err => consola.warn(`${logPrefix} received websocket error`, err) + + socket.onclose = () => { + if (!opened && state.socket === socket) { + dispatch('diagnoseSocketFailure') + } + } + socket.onmessage = event => { let data: WebsocketBasePayload @@ -245,5 +264,28 @@ export const actions = { } else { commit('setSocket', null) } + }, + + async diagnoseSocketFailure ({ getters, commit }) { + const spoolmanUrl: string | undefined = getters.getSpoolmanUrl + + if (!spoolmanUrl) { + return + } + + diagnoseSocketFailureAbortController?.abort() + diagnoseSocketFailureAbortController = new AbortController() + + const result = await diagnoseHttpEndpoint(spoolmanUrl, { + probePath: 'api/v1/info', + timeout: 5000, + signal: diagnoseSocketFailureAbortController.signal + }) + + if (result.kind === 'cancelled') { + return + } + + commit('setSocketDiagnostic', result.kind) } } satisfies ActionTree diff --git a/src/store/spoolman/mutations.ts b/src/store/spoolman/mutations.ts index b50858c6f8..361e593d49 100644 --- a/src/store/spoolman/mutations.ts +++ b/src/store/spoolman/mutations.ts @@ -5,6 +5,7 @@ import type { SpoolmanState, SpoolSelectionDialogState } from '@/store/spoolman/types' +import type { HttpDiagnosticResult } from '@/util/http-endpoint-diagnostics' export const mutations = { /** @@ -42,5 +43,9 @@ export const mutations = { setSocket (state, payload: WebSocket | null) { state.socket?.close() state.socket = payload != null ? markRaw(payload) : null + }, + + setSocketDiagnostic (state, payload: HttpDiagnosticResult['kind'] | null) { + state.socketDiagnostic = payload } } satisfies MutationTree diff --git a/src/store/spoolman/state.ts b/src/store/spoolman/state.ts index e1505d5040..5301580b59 100644 --- a/src/store/spoolman/state.ts +++ b/src/store/spoolman/state.ts @@ -10,7 +10,8 @@ export const defaultState = (): SpoolmanState => { dialog: { show: false }, - socket: null + socket: null, + socketDiagnostic: null } } diff --git a/src/store/spoolman/types.ts b/src/store/spoolman/types.ts index aec4bc9c4b..28df7e1d2a 100644 --- a/src/store/spoolman/types.ts +++ b/src/store/spoolman/types.ts @@ -1,3 +1,5 @@ +import type { HttpDiagnosticResult } from '@/util/http-endpoint-diagnostics' + export interface SpoolmanState { info: Readonly | null; spools: readonly Moonraker.Spoolman.Spool[]; @@ -6,6 +8,7 @@ export interface SpoolmanState { connected: boolean; dialog: SpoolSelectionDialogState; socket: WebSocket | null; + socketDiagnostic: HttpDiagnosticResult['kind'] | null; } export interface Spool extends Omit { diff --git a/src/util/__tests__/http-endpoint-diagnostics.spec.ts b/src/util/__tests__/http-endpoint-diagnostics.spec.ts index cd588059c8..1dc3063a96 100644 --- a/src/util/__tests__/http-endpoint-diagnostics.spec.ts +++ b/src/util/__tests__/http-endpoint-diagnostics.spec.ts @@ -66,6 +66,18 @@ describe('diagnoseHttpEndpoint', () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) + it('probes a custom path when probePath is provided', async () => { + setProtocol('http:') + + const fetchMock = vi.fn().mockResolvedValue({ status: 200 }) + vi.stubGlobal('fetch', fetchMock) + + const result = await diagnoseHttpEndpoint('http://spoolman.local/', { probePath: 'api/v1/info' }) + + expect(result).toEqual({ kind: 'reachable', status: 200 }) + expect(fetchMock.mock.calls[0][0]).toMatch(/^http:\/\/spoolman\.local\/api\/v1\/info\?t=\d+$/) + }) + it('returns cancelled when the signal aborts before the cors fetch settles', async () => { setProtocol('http:') diff --git a/src/util/http-endpoint-diagnostics.ts b/src/util/http-endpoint-diagnostics.ts index 3fea01f19f..632ec47f0e 100644 --- a/src/util/http-endpoint-diagnostics.ts +++ b/src/util/http-endpoint-diagnostics.ts @@ -10,12 +10,13 @@ export type HttpDiagnosticResult = export interface DiagnoseOptions { timeout?: number; signal?: AbortSignal; + probePath?: string; } const diagnoseHttpEndpoint = async (apiUrl: string, options: DiagnoseOptions = {}): Promise => { const debug = (message: string, ...args: unknown[]) => consola.debug(`[diagnoseHttpEndpoint] ${apiUrl} ${message}`, ...args) - const { timeout, signal } = options + const { timeout, signal, probePath = '/server/info' } = options // A secure page can't open an insecure connection; detected before any request. if (window.location.protocol === 'https:' && apiUrl.startsWith('http://')) { @@ -35,7 +36,13 @@ const diagnoseHttpEndpoint = async (apiUrl: string, options: DiagnoseOptions = { .filter(Boolean) ) - const probeUrl = `${apiUrl}/server/info?t=${Date.now()}` + const baseUrl = apiUrl.endsWith('/') + ? apiUrl.slice(0, -1) + : apiUrl + const path = probePath.startsWith('/') + ? probePath + : `/${probePath}` + const probeUrl = `${baseUrl}${path}?t=${Date.now()}` try { const response = await fetch(probeUrl, {