diff --git a/tnoodle-ui/build.gradle.kts b/tnoodle-ui/build.gradle.kts index 98719c69d..e20c8f1b9 100644 --- a/tnoodle-ui/build.gradle.kts +++ b/tnoodle-ui/build.gradle.kts @@ -22,7 +22,7 @@ val yarnInstall = tasks.named("yarn_install") { val yarnBuild = tasks.named("yarn_build") { dependsOn(yarnInstall) - inputs.files(fileTree("src").exclude("*.css")) + inputs.files(fileTree("src/main").exclude("*.css")) inputs.dir("public") inputs.file("package.json") diff --git a/tnoodle-ui/package.json b/tnoodle-ui/package.json index c3860193b..25e564553 100644 --- a/tnoodle-ui/package.json +++ b/tnoodle-ui/package.json @@ -30,7 +30,8 @@ "build": "react-scripts build", "test": "react-scripts test --watchAll --watchAll=false --coverage", "eject": "react-scripts eject", - "prettier": "prettier --check ." + "prettier": "prettier --check .", + "lint": "prettier --write ." }, "eslintConfig": { "extends": [ diff --git a/tnoodle-ui/src/main/api/tnoodle.api.ts b/tnoodle-ui/src/main/api/tnoodle.api.ts index 143e65afa..d64b8bea9 100644 --- a/tnoodle-ui/src/main/api/tnoodle.api.ts +++ b/tnoodle-ui/src/main/api/tnoodle.api.ts @@ -6,6 +6,8 @@ import WcaEvent from "../model/WcaEvent"; import WcaFormat from "../model/WcaFormat"; import Wcif from "../model/Wcif"; import { ScrambleClient } from "./tnoodle.socket"; +import WebsocketBlobResult from "../model/WebsocketBlobResult"; +import FrontendStatus from "../model/FrontendStatus"; let backendUrl = new URL("http://localhost:2014"); export const tNoodleBackend = backendUrl.toString().replace(/\/$/g, ""); @@ -62,6 +64,7 @@ class TnoodleApi { wcif: Wcif, mbld: string, password: string, + status: FrontendStatus, translations?: Translation[] ) => { let payload = { @@ -69,15 +72,13 @@ class TnoodleApi { multiCubes: { requestedScrambles: mbld }, fmcLanguages: fmcTranslationsHelper(translations), zipPassword: !password ? null : password, + frontendStatus: status, }; return scrambleClient.loadScrambles(zipEndpoint, payload, wcif.id); }; - convertToBlob = async (result: { - contentType: string; - payload: string; - }) => { + convertToBlob = async (result: WebsocketBlobResult) => { let { contentType, payload } = result; let res = await fetch(`data:${contentType};base64,${payload}`); diff --git a/tnoodle-ui/src/main/api/tnoodle.socket.ts b/tnoodle-ui/src/main/api/tnoodle.socket.ts index 1e25e1308..071cf6e36 100644 --- a/tnoodle-ui/src/main/api/tnoodle.socket.ts +++ b/tnoodle-ui/src/main/api/tnoodle.socket.ts @@ -1,10 +1,9 @@ import { tNoodleBackend } from "./tnoodle.api"; +import WebsocketBlobResult from "../model/WebsocketBlobResult"; export type ScrambleHandshakeFn = (payload: Record) => void; export type ScrambleProgressFn = (payload: string) => void; -export type ScramblingBlobResult = any & { contentType: string; payload: any }; - enum ScramblingState { Idle, Initiate, @@ -21,10 +20,10 @@ export class ScrambleClient { state: ScramblingState; - contentType: string | null; + contentType: string; - resultPayload: object | null; - errorPayload: object | null; + resultPayload: string; + errorPayload: any; constructor( onHandshake: ScrambleHandshakeFn, @@ -35,9 +34,9 @@ export class ScrambleClient { this.state = ScramblingState.Idle; - this.contentType = null; + this.contentType = FALLBACK_APPLICATION_TYPE; - this.resultPayload = null; + this.resultPayload = ""; this.errorPayload = null; } @@ -45,7 +44,7 @@ export class ScrambleClient { endpoint: String, payload: object, targetMarker: String - ): Promise { + ): Promise { return new Promise((resolve, reject) => { let ws = new WebSocket(BASE_URL + endpoint); @@ -61,8 +60,7 @@ export class ScrambleClient { ws.onclose = (cls) => { if (this.state === ScramblingState.Done && cls.wasClean) { let resultObject = { - contentType: - this.contentType ?? FALLBACK_APPLICATION_TYPE, + contentType: this.contentType, payload: this.resultPayload, }; diff --git a/tnoodle-ui/src/main/components/Main.tsx b/tnoodle-ui/src/main/components/Main.tsx index 39307814b..fdc8ef683 100644 --- a/tnoodle-ui/src/main/components/Main.tsx +++ b/tnoodle-ui/src/main/components/Main.tsx @@ -16,6 +16,7 @@ import EventPickerTable from "./EventPickerTable"; import Interceptor from "./Interceptor"; import "./Main.css"; import VersionInfo from "./VersionInfo"; +import WebsocketBlobResult from "../model/WebsocketBlobResult"; const Main = () => { const [competitionNameFileZip, setCompetitionNameFileZip] = useState(""); @@ -33,8 +34,11 @@ const Main = () => { const generatingScrambles = useSelector( (state: RootState) => state.scramblingSlice.generatingScrambles ); - const officialZipStatus = useSelector( - (state: RootState) => state.scramblingSlice.officialZipStatus + const isValidSignedBuild = useSelector( + (state: RootState) => state.scramblingSlice.isValidSignedBuild + ); + const isAllowedVersion = useSelector( + (state: RootState) => state.scramblingSlice.isAllowedVersion ); const fileZip = useSelector( (state: RootState) => state.scramblingSlice.fileZip @@ -72,9 +76,23 @@ const Main = () => { onScrambleProgress ); + let frontendStatus = { + isStaging: isUsingStaging(), + isManual: competitionId == null, + isSignedBuild: isValidSignedBuild, + isAllowedVersion: isAllowedVersion, + }; + tnoodleApi - .fetchZip(scrambleClient, wcif, mbld, password, translations) - .then((plainZip: { contentType: string; payload: string }) => + .fetchZip( + scrambleClient, + wcif, + mbld, + password, + frontendStatus, + translations + ) + .then((plainZip: WebsocketBlobResult) => dispatch(setFileZip(plainZip)) ) .catch((err: any) => interceptorRef.current?.updateMessage(err)) @@ -90,6 +108,8 @@ const Main = () => { // If TNoodle version is not official (as per VersionInfo) or if we generate scrambles using // a competition from staging, add a [Unofficial] + let officialZipStatus = isValidSignedBuild && isAllowedVersion; + let isUnofficialZip = !officialZipStatus || (competitionId != null && isUsingStaging()); diff --git a/tnoodle-ui/src/main/components/VersionInfo.tsx b/tnoodle-ui/src/main/components/VersionInfo.tsx index 0334e99bd..f74c42291 100644 --- a/tnoodle-ui/src/main/components/VersionInfo.tsx +++ b/tnoodle-ui/src/main/components/VersionInfo.tsx @@ -3,7 +3,10 @@ import { useDispatch } from "react-redux"; import tnoodleApi from "../api/tnoodle.api"; import wcaApi from "../api/wca.api"; import CurrentTnoodle from "../model/CurrentTnoodle"; -import { setOfficialZipStatus } from "../redux/slice/ScramblingSlice"; +import { + setAllowedVersion, + setValidSignedBuild, +} from "../redux/slice/ScramblingSlice"; const VersionInfo = () => { const [currentTnoodle, setCurrentTnoodle] = useState(); @@ -46,20 +49,18 @@ const VersionInfo = () => { }, [dispatch]); // This avoids global state update while rendering - const analyzeVerion = () => { + const analyzeVersion = () => { // We wait until both wca and tnoodle answers if (!allowedTnoodleVersions || !runningVersion) { return; } dispatch( - setOfficialZipStatus( - signatureValid && - allowedTnoodleVersions.includes(runningVersion) - ) + setAllowedVersion(allowedTnoodleVersions.includes(runningVersion)) ); + dispatch(setValidSignedBuild(signatureValid)); }; - useEffect(analyzeVerion, [ + useEffect(analyzeVersion, [ allowedTnoodleVersions, dispatch, runningVersion, diff --git a/tnoodle-ui/src/main/model/FrontendStatus.ts b/tnoodle-ui/src/main/model/FrontendStatus.ts new file mode 100644 index 000000000..92eb3f99e --- /dev/null +++ b/tnoodle-ui/src/main/model/FrontendStatus.ts @@ -0,0 +1,6 @@ +export default interface FrontendStatus { + isStaging: boolean; + isManual: boolean; + isSignedBuild: boolean; + isAllowedVersion: boolean; +} diff --git a/tnoodle-ui/src/main/model/WebsocketBlobResult.ts b/tnoodle-ui/src/main/model/WebsocketBlobResult.ts new file mode 100644 index 000000000..8415199d4 --- /dev/null +++ b/tnoodle-ui/src/main/model/WebsocketBlobResult.ts @@ -0,0 +1,4 @@ +export default interface WebsocketBlobResult { + contentType: string; + payload: string; +} diff --git a/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts b/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts index eb7476f57..9229628bc 100644 --- a/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts +++ b/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts @@ -1,9 +1,11 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import WebsocketBlobResult from "../../model/WebsocketBlobResult"; interface ScramblingState { - fileZip?: { contentType: string; payload: string }; + fileZip?: WebsocketBlobResult; generatingScrambles: boolean; - officialZipStatus: boolean; + isValidSignedBuild: boolean; + isAllowedVersion: boolean; password: string; scramblingProgressCurrent: Record; scramblingProgressTarget: Record; @@ -12,8 +14,9 @@ interface ScramblingState { const initialState: ScramblingState = { fileZip: undefined, generatingScrambles: false, + isValidSignedBuild: false, + isAllowedVersion: false, password: "", - officialZipStatus: true, scramblingProgressCurrent: {}, scramblingProgressTarget: {}, }; @@ -24,17 +27,18 @@ export const scramblingSlice = createSlice({ reducers: { setFileZip: ( state, - action: PayloadAction< - { contentType: string; payload: string } | undefined - > + action: PayloadAction ) => { state.fileZip = action.payload; }, setGeneratingScrambles: (state, action: PayloadAction) => { state.generatingScrambles = action.payload; }, - setOfficialZipStatus: (state, action: PayloadAction) => { - state.officialZipStatus = action.payload; + setValidSignedBuild: (state, action: PayloadAction) => { + state.isValidSignedBuild = action.payload; + }, + setAllowedVersion: (state, action: PayloadAction) => { + state.isAllowedVersion = action.payload; }, setPassword: (state, action: PayloadAction) => { state.password = action.payload; @@ -60,7 +64,8 @@ export const scramblingSlice = createSlice({ export const { setFileZip, - setOfficialZipStatus, + setValidSignedBuild, + setAllowedVersion, setPassword, setGeneratingScrambles, resetScramblingProgressCurrent, diff --git a/tnoodle-ui/src/test/App.test.tsx b/tnoodle-ui/src/test/App.test.tsx index a90b6fabe..435da4dc4 100644 --- a/tnoodle-ui/src/test/App.test.tsx +++ b/tnoodle-ui/src/test/App.test.tsx @@ -1,4 +1,3 @@ -import { configureStore } from "@reduxjs/toolkit"; import { fireEvent } from "@testing-library/react"; import { shuffle } from "lodash"; import React from "react"; @@ -10,15 +9,10 @@ import tnoodleApi from "../main/api/tnoodle.api"; import wcaApi from "../main/api/wca.api"; import Translation from "../main/model/Translation"; import Wcif from "../main/model/Wcif"; -import { competitionSlice } from "../main/redux/slice/CompetitionSlice"; -import { fmcSlice } from "../main/redux/slice/FmcSlice"; -import { informationSlice } from "../main/redux/slice/InformationSlice"; -import { mbldSlice } from "../main/redux/slice/MbldSlice"; -import { scramblingSlice } from "../main/redux/slice/ScramblingSlice"; -import { wcifSlice } from "../main/redux/slice/WcifSlice"; import { defaultWcif } from "../main/util/wcif.util"; import { bestMbldAttempt, + defaultStatus, events, formats, languages, @@ -32,12 +26,14 @@ import { scrambleProgram, wcifs, } from "./mock/wca.api.test.mock"; +import FrontendStatus from "../main/model/FrontendStatus"; let container = document.createElement("div"); let wcif: Wcif | null = null; let mbld: string | null = null; let password: string | null = null; +let status: FrontendStatus | null = null; let translations: Translation[] | undefined; beforeEach(() => { // setup a DOM element as a render target @@ -68,13 +64,14 @@ beforeEach(() => { ); jest.spyOn(tnoodleApi, "fetchZip").mockImplementation( - (scrambleClient, _wcif, _mbld, _password, _translations) => { + (scrambleClient, _wcif, _mbld, _password, _status, _translations) => { wcif = _wcif; mbld = _mbld; password = _password; + status = _status; translations = _translations; - return Promise.resolve({ ...axiosResponse, data: plainZip }); + return Promise.resolve(plainZip); } ); @@ -147,6 +144,7 @@ it("Just generate scrambles", async () => { expect(wcif!.events.length).toBe(1); expect(password).toBe(""); + expect(status).toEqual(defaultStatus); }); it("Changes on 333, scramble", async () => { diff --git a/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts b/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts index fa7d41ae2..eee69ce83 100644 --- a/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts +++ b/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts @@ -199,6 +199,13 @@ export const plainZip = { payload: "UEsDBBQACAgIAK...", }; +export const defaultStatus = { + isStaging: false, + isManual: true, + isSignedBuild: true, + isAllowedVersion: true, +}; + export const bestMbldAttempt = { solved: 60, attempted: 60, diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/WatermarkPdfWrapper.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/WatermarkPdfWrapper.kt index 81fbc5db7..d839f0dd6 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/WatermarkPdfWrapper.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/WatermarkPdfWrapper.kt @@ -28,9 +28,30 @@ class WatermarkPdfWrapper( val pr = PdfReader(original.render()) for (pageN in 1..pr.numberOfPages) { - val page = getImportedPage(pr, pageN) - document.newPage() + + // Frontend watermark + if (watermark != null) { + val transparentState = PdfGState().apply { + setFillOpacity(WATERMARK_OPACITY) + setStrokeOpacity(WATERMARK_OPACITY) + } + + cb.saveState() + cb.setGState(transparentState) + + val diagRotation = atan(PAGE_SIZE.height / PAGE_SIZE.width) * (180f / PI) + + ColumnText.showTextAligned(cb, + Element.ALIGN_CENTER, Phrase(watermark, Font(FontUtil.NOTO_SANS_FONT, 72f, Font.BOLD)), + (PAGE_SIZE.left + PAGE_SIZE.right) / 2, (PAGE_SIZE.top + PAGE_SIZE.bottom) / 2, diagRotation.toFloat()) + + cb.restoreState() + } + + // add the imported page *after* potential watermarks + // so the watermark stays in the background + val page = getImportedPage(pr, pageN) cb.addTemplate(page, 0f, 0f) val rect = pr.getBoxSize(pageN, "art") @@ -64,28 +85,11 @@ class WatermarkPdfWrapper( ColumnText.showTextAligned(cb, Element.ALIGN_CENTER, Phrase(generatedBy), (PAGE_SIZE.left + PAGE_SIZE.right) / 2, footerRect.top - footerRect.height / 4, 0f) - - // Staging watermark - if (watermark != null) { - val transparentState = PdfGState().apply { - setFillOpacity(0.2f) - } - - cb.saveState() - cb.setGState(transparentState) - - val diagRotation = atan(PAGE_SIZE.height / PAGE_SIZE.width) * (180f / PI) - - ColumnText.showTextAligned(cb, - Element.ALIGN_CENTER, Phrase(watermark, Font(FontUtil.NOTO_SANS_FONT, 100f, Font.BOLD)), - (PAGE_SIZE.left + PAGE_SIZE.right) / 2, (PAGE_SIZE.top + PAGE_SIZE.bottom) / 2, diagRotation.toFloat()) - - cb.restoreState() - } } } companion object { const val HEADER_AND_FOOTER_HEIGHT_RATIO = 12 + const val WATERMARK_OPACITY = 0.1f } } diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt index e1b2609d4..3ea074541 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt @@ -86,12 +86,12 @@ object WCIFDataBuilder { } fun TNoodleStatusExtension.pickWatermarkPhrase(): String? { - return if (!isOfficialBuild) { + return if (isStaging) { + WATERMARK_STAGING + } else if (!isSignedBuild) { WATERMARK_UNOFFICIAL - } else if (!isRecentVersion) { + } else if (!isAllowedVersion) { WATERMARK_OUTDATED - } else if (isStaging) { - WATERMARK_STAGING } else null } diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt index 8e66d2fc0..03a37142a 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt @@ -105,7 +105,7 @@ data class SheetCopyCountExtension(val numCopies: Int) : ExtensionBuilder() { @Serializable @SerialName(TNoodleStatusExtension.ID) -data class TNoodleStatusExtension(val isStaging: Boolean, val isManual: Boolean, val isOfficialBuild: Boolean, val isRecentVersion: Boolean) : ExtensionBuilder() { +data class TNoodleStatusExtension(val isStaging: Boolean, val isManual: Boolean, val isSignedBuild: Boolean, val isAllowedVersion: Boolean) : ExtensionBuilder() { override val id get() = ID override val specUrl get() = SPEC_URL