A basic update checker (#186)

This commit is contained in:
Aria 2022-05-31 23:19:42 +02:00 committed by GitHub
parent 832c128c43
commit a9e86113ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 337 additions and 4 deletions

View file

@ -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()
}
}

View 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
)
}

View file

@ -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,

View file

@ -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={() => {

View 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

View file

@ -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};

View file

@ -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!,

View file

@ -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;