A basic update checker (#186)
This commit is contained in:
parent
832c128c43
commit
a9e86113ba
9 changed files with 337 additions and 4 deletions
|
@ -21,6 +21,7 @@ import type {OnDiskState} from '@theatre/core/projects/store/storeTypes'
|
|||
import type {Deferred} from '@theatre/shared/utils/defer'
|
||||
import {defer} from '@theatre/shared/utils/defer'
|
||||
import type {ProjectId} from '@theatre/shared/utils/ids'
|
||||
import checkForUpdates from './checkForUpdates'
|
||||
|
||||
export type CoreExports = typeof _coreExports
|
||||
|
||||
|
@ -96,6 +97,7 @@ export class Studio {
|
|||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
this.ui.render()
|
||||
checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
71
theatre/studio/src/checkForUpdates.ts
Normal file
71
theatre/studio/src/checkForUpdates.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {val} from '@theatre/dataverse'
|
||||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||
import getStudio from './getStudio'
|
||||
import type {UpdateCheckerResponse} from './store/types'
|
||||
|
||||
const UPDATE_CHECK_INTERVAL = 30 * 60 * 1000 // check for updates every 30 minutes
|
||||
const TIME_TO_WAIT_ON_ERROR = 1000 * 60 * 60 // an hour
|
||||
|
||||
export default async function checkForUpdates() {
|
||||
while (true) {
|
||||
const state = val(getStudio().atomP.ahistoric.updateChecker)
|
||||
if (state) {
|
||||
if (state.result !== 'error') {
|
||||
const lastChecked = state.lastChecked
|
||||
const now = Date.now()
|
||||
const timeElapsedSinceLastCheckedForUpdate = Math.abs(now - lastChecked)
|
||||
|
||||
// doing Math.max in case the clock has shifted
|
||||
if (timeElapsedSinceLastCheckedForUpdate < UPDATE_CHECK_INTERVAL) {
|
||||
await wait(
|
||||
UPDATE_CHECK_INTERVAL - timeElapsedSinceLastCheckedForUpdate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
new Request(
|
||||
`https://updates.theatrejs.com/updates/${process.env.version}`,
|
||||
),
|
||||
)
|
||||
if (response.ok) {
|
||||
const json = await response.json()
|
||||
if (!isValidUpdateCheckerResponse(json)) {
|
||||
throw new Error(`Bad response`)
|
||||
}
|
||||
getStudio().transaction(({drafts}) => {
|
||||
drafts.ahistoric.updateChecker = {
|
||||
lastChecked: Date.now(),
|
||||
result: {...json},
|
||||
}
|
||||
})
|
||||
|
||||
await wait(1000)
|
||||
} else {
|
||||
throw new Error(`HTTP Error ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO log an error here
|
||||
|
||||
await wait(TIME_TO_WAIT_ON_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
function isValidUpdateCheckerResponse(
|
||||
json: unknown,
|
||||
): json is UpdateCheckerResponse {
|
||||
if (typeof json !== 'object') return false
|
||||
const obj = json as $IntentionalAny
|
||||
if (typeof obj['hasUpdates'] !== 'boolean') return false
|
||||
// could use a runtime type checker but not important yet
|
||||
return (
|
||||
(obj.hasUpdates === true &&
|
||||
typeof obj.newVersion === 'string' &&
|
||||
typeof obj.releasePage === 'string') ||
|
||||
obj.hasUpdates === false
|
||||
)
|
||||
}
|
|
@ -5,6 +5,10 @@ import type {IRange, StrictRecord} from '@theatre/shared/utils/types'
|
|||
import type {PointableSet} from '@theatre/shared/utils/PointableSet'
|
||||
import type {StudioSheetItemKey} from '@theatre/shared/utils/ids'
|
||||
|
||||
export type UpdateCheckerResponse =
|
||||
| {hasUpdates: true; newVersion: string; releasePage: string}
|
||||
| {hasUpdates: false}
|
||||
|
||||
export type StudioAhistoricState = {
|
||||
/**
|
||||
* undefined means the outline menu is pinned
|
||||
|
@ -26,6 +30,11 @@ export type StudioAhistoricState = {
|
|||
distanceFromVerticalEdge: number
|
||||
}
|
||||
}
|
||||
updateChecker?: {
|
||||
// timestamp of the last time we checked for updates
|
||||
lastChecked: number
|
||||
result: UpdateCheckerResponse | 'error'
|
||||
}
|
||||
projects: {
|
||||
stateByProjectId: StrictRecord<
|
||||
ProjectId,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {usePrism, useVal} from '@theatre/react'
|
||||
import getStudio from '@theatre/studio/getStudio'
|
||||
import React from 'react'
|
||||
import React, {useRef} from 'react'
|
||||
import styled from 'styled-components'
|
||||
import type {$IntentionalAny} from '@theatre/dataverse/dist/types'
|
||||
import useTooltip from '@theatre/studio/uiComponents/Popover/useTooltip'
|
||||
|
@ -13,9 +13,13 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Details,
|
||||
Ellipsis,
|
||||
Outline,
|
||||
} from '@theatre/studio/uiComponents/icons'
|
||||
import {shouldShowDetailD} from '@theatre/studio/panels/DetailPanel/DetailPanel'
|
||||
import ToolbarIconButton from '@theatre/studio/uiComponents/toolbar/ToolbarIconButton'
|
||||
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
|
||||
import MoreMenu from './MoreMenu/MoreMenu'
|
||||
|
||||
const Container = styled.div`
|
||||
height: 36px;
|
||||
|
@ -48,6 +52,16 @@ const SubContainer = styled.div`
|
|||
gap: 8px;
|
||||
`
|
||||
|
||||
const HasUpdatesBadge = styled.div`
|
||||
position: absolute;
|
||||
background: #40aaa4;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
right: -2px;
|
||||
top: -2px;
|
||||
`
|
||||
|
||||
const GlobalToolbar: React.FC = () => {
|
||||
const conflicts = usePrism(() => {
|
||||
const ephemeralStateOfAllProjects = val(
|
||||
|
@ -78,6 +92,27 @@ const GlobalToolbar: React.FC = () => {
|
|||
const detailsPinned = useVal(getStudio().atomP.ahistoric.pinDetails)
|
||||
const showOutline = useVal(getStudio().atomP.ephemeral.showOutline)
|
||||
const showDetails = useVal(shouldShowDetailD)
|
||||
const hasUpdates =
|
||||
useVal(getStudio().atomP.ahistoric.updateChecker.result.hasUpdates) === true
|
||||
|
||||
const [moreMenu, openMoreMenu] = usePopover(
|
||||
() => {
|
||||
const triggerBounds = moreMenuTriggerRef.current!.getBoundingClientRect()
|
||||
return {
|
||||
debugName: 'More Menu',
|
||||
|
||||
constraints: {
|
||||
maxX: triggerBounds.right,
|
||||
maxY: 8,
|
||||
},
|
||||
verticalGap: 2,
|
||||
}
|
||||
},
|
||||
() => {
|
||||
return <MoreMenu />
|
||||
},
|
||||
)
|
||||
const moreMenuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
|
@ -107,6 +142,17 @@ const GlobalToolbar: React.FC = () => {
|
|||
<ExtensionToolbar toolbarId="global" />
|
||||
</SubContainer>
|
||||
<SubContainer>
|
||||
{moreMenu}
|
||||
<ToolbarIconButton
|
||||
ref={moreMenuTriggerRef}
|
||||
onClick={(e) => {
|
||||
openMoreMenu(e, moreMenuTriggerRef.current!)
|
||||
}}
|
||||
>
|
||||
<Ellipsis />
|
||||
{hasUpdates && <HasUpdatesBadge />}
|
||||
</ToolbarIconButton>
|
||||
|
||||
<PinButton
|
||||
ref={triggerButtonRef as $IntentionalAny}
|
||||
onClick={() => {
|
||||
|
|
203
theatre/studio/src/toolbars/MoreMenu/MoreMenu.tsx
Normal file
203
theatre/studio/src/toolbars/MoreMenu/MoreMenu.tsx
Normal file
|
@ -0,0 +1,203 @@
|
|||
import {useVal} from '@theatre/react'
|
||||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||
import getStudio from '@theatre/studio/getStudio'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const Container = styled.div`
|
||||
width: 138px;
|
||||
border-radius: 2px;
|
||||
background-color: rgba(42, 45, 50, 0.9);
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.25), 0px 2px 6px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(14px);
|
||||
pointer-events: auto;
|
||||
// makes the edges of the item highlights match the rounded corners
|
||||
overflow: hidden;
|
||||
|
||||
@supports not (backdrop-filter: blur()) {
|
||||
background-color: rgba(42, 45, 50, 0.98);
|
||||
}
|
||||
`
|
||||
|
||||
const Item = styled.div`
|
||||
position: relative;
|
||||
padding: 0px 12px;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
height: 32px;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
`
|
||||
|
||||
const Link = styled(Item)`
|
||||
&:before {
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: ' ';
|
||||
inset: 3px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
/* background-color: #398995; */
|
||||
color: white !important;
|
||||
&:before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const VersionContainer = styled(Item)`
|
||||
padding-top: 12px;
|
||||
padding-bottom: 10px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
`
|
||||
|
||||
const VersionLabel = styled.div`
|
||||
font-weight: 600;
|
||||
`
|
||||
|
||||
const VersionValueRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
const VersionValue = styled.div`
|
||||
margin-left: 2px;
|
||||
`
|
||||
|
||||
const Divider = styled.div`
|
||||
height: 1px;
|
||||
margin: 0 2px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
`
|
||||
|
||||
const UpdateDot = styled.div`
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #40aaa4;
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
border-radius: 50%;
|
||||
`
|
||||
|
||||
const version: string = process.env.version ?? '0.4.0'
|
||||
|
||||
const untaggedVersion: string = version.match(/^[^\-]+/)![0]
|
||||
|
||||
const MoreMenu = React.forwardRef((props: {}, ref) => {
|
||||
const hasUpdates = useVal(
|
||||
getStudio().atomP.ahistoric.updateChecker.result.hasUpdates,
|
||||
)
|
||||
|
||||
return (
|
||||
<Container ref={ref as $IntentionalAny}>
|
||||
<Link
|
||||
as="a"
|
||||
href="https://docs.theatrejs.com"
|
||||
className=""
|
||||
target="_blank"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
as="a"
|
||||
href={`https://docs.theatrejs.com/changelog`}
|
||||
className=""
|
||||
target="_blank"
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
|
||||
<Divider />
|
||||
<Link
|
||||
as="a"
|
||||
href="https://github.com/theatre-js/theatre"
|
||||
className=""
|
||||
target="_blank"
|
||||
>
|
||||
Github
|
||||
</Link>
|
||||
<Link
|
||||
as="a"
|
||||
href="https://twitter.com/theatre_js"
|
||||
className=""
|
||||
target="_blank"
|
||||
>
|
||||
Twitter
|
||||
</Link>
|
||||
<Link
|
||||
className=""
|
||||
as="a"
|
||||
href="https://discord.gg/bm9f8F9Y9N"
|
||||
target="_blank"
|
||||
>
|
||||
Discord
|
||||
</Link>
|
||||
<Divider />
|
||||
<VersionContainer>
|
||||
<VersionLabel>Version</VersionLabel>
|
||||
<VersionValueRow>
|
||||
<VersionValue>
|
||||
{version}{' '}
|
||||
{hasUpdates === true
|
||||
? '(outdated)'
|
||||
: hasUpdates === false
|
||||
? '(latest)'
|
||||
: ''}
|
||||
</VersionValue>
|
||||
</VersionValueRow>
|
||||
</VersionContainer>
|
||||
{hasUpdates === true && (
|
||||
<>
|
||||
<Divider />
|
||||
<Link
|
||||
as="a"
|
||||
href={`https://docs.theatrejs.com/update#${encodeURIComponent(
|
||||
untaggedVersion,
|
||||
)}`}
|
||||
className=""
|
||||
target="_blank"
|
||||
>
|
||||
Update
|
||||
<UpdateDot />
|
||||
</Link>
|
||||
<Link
|
||||
as="a"
|
||||
href={`https://docs.theatrejs.com/changelog#${encodeURIComponent(
|
||||
untaggedVersion,
|
||||
)}`}
|
||||
className=""
|
||||
target="_blank"
|
||||
>
|
||||
What's new?
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
|
||||
export default MoreMenu
|
|
@ -1,7 +1,7 @@
|
|||
import styled from 'styled-components'
|
||||
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||
import type {ComponentPropsWithRef, ReactNode} from 'react';
|
||||
import React, { forwardRef} from 'react'
|
||||
import type {ComponentPropsWithRef, ReactNode} from 'react'
|
||||
import React, {forwardRef} from 'react'
|
||||
|
||||
const Container = styled.button<{pinned?: boolean}>`
|
||||
${pointerEventsAutoInNormalMode};
|
||||
|
|
|
@ -44,6 +44,7 @@ type Opts = {
|
|||
pointerDistanceThreshold?: number
|
||||
closeOnClickOutside?: boolean
|
||||
constraints?: AbsolutePlacementBoxConstraints
|
||||
verticalGap?: number
|
||||
}
|
||||
|
||||
export default function usePopover(
|
||||
|
@ -123,6 +124,7 @@ export default function usePopover(
|
|||
onClickOutside={state.onClickOutside}
|
||||
onPointerOutside={state.onPointerOutside}
|
||||
constraints={state.opts.constraints}
|
||||
verticalGap={state.opts.verticalGap}
|
||||
/>
|
||||
</PopoverAutoCloseLock.Provider>,
|
||||
portalLayer!,
|
||||
|
|
|
@ -7,7 +7,7 @@ import mergeRefs from 'react-merge-refs'
|
|||
import MinimalTooltip from '@theatre/studio/uiComponents/Popover/MinimalTooltip'
|
||||
import ToolbarSwitchSelectContainer from './ToolbarSwitchSelectContainer'
|
||||
|
||||
const Container = styled.button`
|
||||
export const Container = styled.button`
|
||||
${pointerEventsAutoInNormalMode};
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
Loading…
Reference in a new issue