mirror of
https://github.com/penpot/penpot.git
synced 2026-03-24 12:20:53 +01:00
* ✨ Use update-when for update dashboard state This make updates more consistent and reduces possible eventual consistency issues in out of order events execution. * 🐛 Detect stale JS modules at boot and force reload When the browser serves cached JS files from a previous deployment alongside a fresh index.html, code-split modules reference keyword constants that do not exist in the stale shared.js, causing TypeError crashes. This adds a compile-time version tag (via goog-define / closure-defines) that is baked into the JS bundle. At boot, it is compared against the runtime version tag from index.html (which is always fresh due to no-cache headers). If they differ, the app forces a hard page reload before initializing, ensuring all JS modules come from the same build. * 📎 Ensure consistent version across builds on github e2e test workflow --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz>
256 lines
8.3 KiB
Clojure
256 lines
8.3 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.config
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.flags :as flags]
|
|
[app.common.logging :as log]
|
|
[app.common.time :as ct]
|
|
[app.common.uri :as u]
|
|
[app.common.version :as v]
|
|
[app.util.avatars :as avatars]
|
|
[app.util.extends]
|
|
[app.util.globals :refer [global location]]
|
|
[app.util.navigator :as nav]
|
|
[app.util.object :as obj]
|
|
[app.util.storage :as sto]
|
|
[app.util.timers :as ts]
|
|
[cuerdas.core :as str]))
|
|
|
|
(set! *assert* js/goog.DEBUG)
|
|
|
|
;; --- Auxiliar Functions
|
|
|
|
(def valid-browsers
|
|
#{:chrome :firefox :safari :safari-16 :safari-17 :edge :other})
|
|
|
|
(def valid-platforms
|
|
#{:windows :linux :macos :other})
|
|
|
|
(defn- parse-browser
|
|
[]
|
|
(let [user-agent (-> (nav/get-user-agent) str/lower)
|
|
check-chrome? (fn [] (str/includes? user-agent "chrom"))
|
|
check-firefox? (fn [] (str/includes? user-agent "firefox"))
|
|
check-edge? (fn [] (str/includes? user-agent "edg"))
|
|
check-safari? (fn [] (str/includes? user-agent "safari"))
|
|
check-safari-16? (fn [] (and (check-safari?) (str/includes? user-agent "version/16")))
|
|
check-safari-17? (fn [] (and (check-safari?) (str/includes? user-agent "version/17")))]
|
|
(cond
|
|
^boolean (check-edge?) :edge
|
|
^boolean (check-chrome?) :chrome
|
|
^boolean (check-firefox?) :firefox
|
|
^boolean (check-safari-16?) :safari-16
|
|
^boolean (check-safari-17?) :safari-17
|
|
^boolean (check-safari?) :safari
|
|
:else :unknown)))
|
|
|
|
(defn- parse-platform
|
|
[]
|
|
(let [user-agent (str/lower (nav/get-user-agent))
|
|
check-windows? (fn [] (str/includes? user-agent "windows"))
|
|
check-linux? (fn [] (str/includes? user-agent "linux"))
|
|
check-macos? (fn [] (str/includes? user-agent "mac os"))]
|
|
(cond
|
|
^boolean (check-windows?) :windows
|
|
^boolean (check-linux?) :linux
|
|
^boolean (check-macos?) :macos
|
|
:else :other)))
|
|
|
|
(defn- parse-target
|
|
[global]
|
|
(if (some? (obj/get global "document"))
|
|
:browser
|
|
:webworker))
|
|
|
|
(defn- parse-flags
|
|
[global]
|
|
(let [flags (obj/get global "penpotFlags" "")
|
|
flags (sequence (map keyword) (str/words flags))]
|
|
(flags/parse flags/default flags)))
|
|
|
|
(defn- parse-version
|
|
[global]
|
|
(-> (obj/get global "penpotVersion")
|
|
(v/parse)))
|
|
|
|
(defn parse-build-date
|
|
[global]
|
|
(let [date (obj/get global "penpotBuildDate")]
|
|
(if (= date "%buildDate%")
|
|
"unknown"
|
|
date)))
|
|
|
|
;; --- Compile-time version tag
|
|
;;
|
|
;; This value is baked into the compiled JS at build time via closure-defines,
|
|
;; so it travels with the JS bundle. In contrast, `version-tag` (below) is read
|
|
;; at runtime from globalThis.penpotVersionTag which is set by the always-fresh
|
|
;; index.html. Comparing the two lets us detect when the browser has loaded
|
|
;; stale cached JS files.
|
|
|
|
(goog-define compiled-version-tag "develop")
|
|
|
|
;; --- Global Config Vars
|
|
|
|
(def default-theme "default")
|
|
(def default-language "en")
|
|
|
|
(def themes (obj/get global "penpotThemes"))
|
|
|
|
(def build-date (parse-build-date global))
|
|
(def flags (parse-flags global))
|
|
(def target (parse-target global))
|
|
(def browser (parse-browser))
|
|
(def platform (parse-platform))
|
|
|
|
(def version (parse-version global))
|
|
(def version-tag (obj/get global "penpotVersionTag"))
|
|
|
|
(defn stale-build?
|
|
"Returns true when the compiled JS was built with a different version
|
|
tag than the one present in the current index.html. This indicates
|
|
the browser has cached JS from a previous deployment."
|
|
^boolean
|
|
[]
|
|
(not= compiled-version-tag version-tag))
|
|
|
|
;; --- Throttled reload
|
|
;;
|
|
;; A generic reload mechanism with loop protection via sessionStorage.
|
|
;; Used by both the boot-time stale-build check and the runtime
|
|
;; stale-asset error handler.
|
|
|
|
(def ^:private reload-storage-key "penpot-last-reload-timestamp")
|
|
(def ^:private reload-cooldown-ms 30000)
|
|
|
|
(defn throttled-reload
|
|
"Force a hard page reload unless one was already triggered within the
|
|
last 30 seconds (tracked in sessionStorage). Returns true when a
|
|
reload is initiated, false when suppressed."
|
|
[& {:keys [reason]}]
|
|
(let [now (ct/now)
|
|
prev-ts (-> (sto/get-item sto/session-storage reload-storage-key)
|
|
(d/parse-integer))]
|
|
(if (and (some? prev-ts)
|
|
(< (- now prev-ts) reload-cooldown-ms))
|
|
(do
|
|
(log/wrn :hint "reload suppressed (cooldown active)"
|
|
:reason reason)
|
|
false)
|
|
(do
|
|
(log/wrn :hint "forcing page reload" :reason reason)
|
|
(sto/set-item sto/session-storage reload-storage-key (str now))
|
|
(ts/asap #(.reload ^js location true))
|
|
true))))
|
|
|
|
(def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI"))
|
|
(def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI"))
|
|
(def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
|
|
(def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
|
|
(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot.app/penpothub/plugins"))
|
|
(def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" [])))
|
|
(def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/"))
|
|
|
|
;; We set the current parsed flags under common for make
|
|
;; it available for common code without the need to pass
|
|
;; the flags all arround on parameters.
|
|
(set! app.common.flags/*current* flags)
|
|
|
|
(defn- normalize-uri
|
|
[uri-str]
|
|
;; Ensure that the path always ends with "/"; this ensures that
|
|
;; all path join operations works as expected.
|
|
(u/ensure-path-slash uri-str))
|
|
|
|
(def public-uri
|
|
(normalize-uri (or (obj/get global "penpotPublicURI")
|
|
(obj/get location "origin"))))
|
|
|
|
(def mcp-ws-uri
|
|
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
|
|
(u/join public-uri "mcp/ws")))
|
|
|
|
(def rasterizer-uri
|
|
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
|
|
public-uri))
|
|
|
|
(def worker-uri
|
|
(obj/get global "penpotWorkerURI" "/js/worker/main.js"))
|
|
|
|
(defn external-feature-flag
|
|
[flag value]
|
|
(let [f (obj/get global "externalFeatureFlag")]
|
|
(when (fn? f)
|
|
(f flag value))))
|
|
|
|
(defn external-session-id
|
|
[]
|
|
(let [f (obj/get global "externalSessionId")]
|
|
(when (fn? f) (f))))
|
|
|
|
(defn external-context-info
|
|
[]
|
|
(let [f (obj/get global "externalContextInfo")]
|
|
(when (fn? f) (f))))
|
|
|
|
(defn initialize-external-context-info
|
|
[]
|
|
(let [f (obj/get global "initializeExternalConfigInfo")]
|
|
(when (fn? f) (f))))
|
|
|
|
(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str))
|
|
(def mcp-help-center-uri "https://help.penpot.app/technical-guide/")
|
|
|
|
;; --- Helper Functions
|
|
|
|
(defn ^boolean check-browser? [candidate]
|
|
(dm/assert! (contains? valid-browsers candidate))
|
|
(if (= candidate :safari)
|
|
(contains? #{:safari :safari-16 :safari-17} browser)
|
|
(= candidate browser)))
|
|
|
|
(defn ^boolean check-platform? [candidate]
|
|
(dm/assert! (contains? valid-platforms candidate))
|
|
(= candidate platform))
|
|
|
|
(defn resolve-profile-photo-url
|
|
[{:keys [photo-id fullname name color] :as profile}]
|
|
(if (nil? photo-id)
|
|
(avatars/generate {:name (or fullname name) :color color})
|
|
(dm/str (u/join public-uri "assets/by-id/" photo-id))))
|
|
|
|
(defn resolve-team-photo-url
|
|
[{:keys [photo-id name] :as team}]
|
|
(if (nil? photo-id)
|
|
(avatars/generate {:name name})
|
|
(dm/str (u/join public-uri "assets/by-id/" photo-id))))
|
|
|
|
(defn resolve-media
|
|
[id]
|
|
(dm/str (u/join public-uri "assets/by-id/" (str id))))
|
|
|
|
(defn resolve-file-media
|
|
([media]
|
|
(resolve-file-media media false))
|
|
([{:keys [id data-uri] :as media} thumbnail?]
|
|
(if data-uri
|
|
data-uri
|
|
(dm/str
|
|
(cond-> (u/join public-uri "assets/by-file-media-id/")
|
|
(true? thumbnail?) (u/join (dm/str id "/thumbnail"))
|
|
(false? thumbnail?) (u/join (dm/str id)))))))
|
|
|
|
(defn resolve-href
|
|
[resource]
|
|
(let [href (-> public-uri
|
|
(u/ensure-path-slash)
|
|
(u/join resource)
|
|
(get :path))]
|
|
(str href "?version=" version-tag)))
|