Integrated update experience (#259)

This commit is contained in:
Gregory Schier
2025-10-01 09:36:36 -07:00
committed by GitHub
parent 757d28c235
commit 9a94a15c82
35 changed files with 631 additions and 155 deletions

View File

@@ -4,7 +4,6 @@ import { settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../lib/appInfo';
import { CargoFeature } from './CargoFeature';
import { BadgeButton } from './core/BadgeButton';
import type { ButtonProps } from './core/Button';
@@ -31,10 +30,6 @@ function LicenseBadgeCmp() {
const { check } = useLicense();
const settings = useAtomValue(settingsAtom);
if (appInfo.isDev) {
return null;
}
if (check.error) {
// Failed to check for license. Probably a network or server error so just don't
// show anything.

View File

@@ -65,6 +65,17 @@ export function SettingsGeneral() {
{ label: 'Manual', value: 'manual' },
]}
/>
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.autoDownloadUpdates}
disabled={!settings.autoupdate}
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates"
onChange={(autoDownloadUpdates) =>
patchModel(workspace, { autoDownloadUpdates })
}
/>
<Separator className="my-4" />
</CargoFeature>
<Select

View File

@@ -1,13 +1,14 @@
import { type } from '@tauri-apps/plugin-os';
import { useFonts } from '@yaakapp-internal/fonts';
import { useLicense } from '@yaakapp-internal/license';
import type { EditorKeymap } from '@yaakapp-internal/models';
import type { EditorKeymap, Settings } from '@yaakapp-internal/models';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { clamp } from '../../lib/clamp';
import { showConfirm } from '../../lib/confirm';
import { CargoFeature } from '../CargoFeature';
import { Checkbox } from '../core/Checkbox';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
@@ -31,7 +32,6 @@ export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const fonts = useFonts();
const license = useLicense();
if (settings == null || workspace == null) {
return null;
@@ -127,31 +127,9 @@ export function SettingsInterface() {
title="Colorize Request Methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/>
{license.check.data?.type === 'personal_use' && (
<Checkbox
checked={!settings.licenseBadge}
title="Hide personal use badge"
onChange={async (hide) => {
if (hide) {
const confirmed = await showConfirm({
id: 'hide-license-badge',
title: 'Hide License Badge',
confirmText: 'Hide Badge',
description: (
<>
Only proceed if youre using Yaak for personal projects only. If youre using it
at work, please <Link href="https://yaak.app/">Purchase a License</Link>.
</>
),
requireTyping: 'Personal Use',
color: 'notice',
});
if (!confirmed) return;
}
await patchModel(settings, { licenseBadge: !hide });
}}
/>
)}
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
{type() !== 'macos' && (
<Checkbox
@@ -164,3 +142,44 @@ export function SettingsInterface() {
</VStack>
);
}
function LicenseSettings({ settings }: { settings: Settings }) {
const license = useLicense();
if (license.check.data?.type !== 'personal_use') {
return null;
}
return (
<Checkbox
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: 'hide-license-badge',
title: 'Confirm Personal Use',
confirmText: 'Confirm',
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{' '}
<strong>If youre using Yaak at work, a license is required.</strong>
</p>
<p>
Licenses help keep Yaak independent and sustainable.{' '}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
</p>
</VStack>
),
requireTyping: 'Personal Use',
color: 'info',
});
if (!confirmed) {
return; // Cancel
}
}
await patchModel(settings, { hideLicenseBadge });
}}
/>
);
}

View File

@@ -62,7 +62,7 @@ function SettingsLicenseCmp() {
<p>
<Link
noUnderline
href="https://yaak.app/pricing"
href="https://yaak.app/pricing?s=learn"
className="text-sm text-notice opacity-80 hover:opacity-100"
>
Learn More
@@ -90,7 +90,7 @@ function SettingsLicenseCmp() {
<Button
color="secondary"
size="sm"
onClick={() => openUrl('https://yaak.app/dashboard')}
onClick={() => openUrl('https://yaak.app/dashboard?s=support')}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
@@ -104,7 +104,7 @@ function SettingsLicenseCmp() {
<Button
color="secondary"
size="sm"
onClick={() => openUrl('https://yaak.app/pricing?ref=app.yaak.desktop')}
onClick={() => openUrl('https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop')}
rightSlot={<Icon icon="external_link" />}
>
Purchase

View File

@@ -0,0 +1,25 @@
import { useState } from 'react';
import type { ButtonProps } from './Button';
import { Button } from './Button';
export function ButtonInfiniteLoading({
onClick,
isLoading,
loadingChildren,
children,
...props
}: ButtonProps & { loadingChildren?: string }) {
const [localIsLoading, setLocalIsLoading] = useState<boolean>(false);
return (
<Button
isLoading={localIsLoading || isLoading}
onClick={(e) => {
setLocalIsLoading(true);
onClick?.(e);
}}
{...props}
>
{localIsLoading ? (loadingChildren ?? children) : children}
</Button>
);
}

View File

@@ -40,7 +40,7 @@ export function Checkbox({
className={classNames(
'appearance-none w-4 h-4 flex-shrink-0 border border-border',
'rounded outline-none ring-0',
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%] ',
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%]',
disabled && 'border-dotted',
)}
type="checkbox"
@@ -58,7 +58,7 @@ export function Checkbox({
</div>
</div>
{!hideLabel && (
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
<div className={classNames('text-sm', fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{title}
</div>
)}

View File

@@ -16,7 +16,7 @@ export interface ToastProps {
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest['icon'];
icon?: ShowToastRequest['icon'] | null;
color?: ShowToastRequest['color'];
}
@@ -42,7 +42,7 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
[open],
);
const toastIcon = icon ?? (color && color in ICONS && ICONS[color]);
const toastIcon = icon === null ? null : icon ?? (color && color in ICONS && ICONS[color]);
return (
<m.div

View File

@@ -1,5 +1,7 @@
import deepEqual from '@gilbarbara/deep-equal';
import type { UpdateInfo } from '@yaakapp-internal/tauri';
import type { Atom } from 'jotai';
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import type { SplitLayoutLayout } from '../components/core/SplitLayout';
import { atomWithKVStorage } from './atoms/atomWithKVStorage';
@@ -16,3 +18,5 @@ export const workspaceLayoutAtom = atomWithKVStorage<SplitLayoutLayout>(
'workspace_layout',
'horizontal',
);
export const updateAvailableAtom = atom<Omit<UpdateInfo, 'replyEventId'> | null>(null);

20
src-web/lib/color.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Color } from '@yaakapp-internal/plugins';
const colors: Record<Color, boolean> = {
primary: true,
secondary: true,
success: true,
notice: true,
warning: true,
danger: true,
info: true,
};
export function stringToColor(str: string | null): Color | null {
if (!str) return null;
const strLower = str.toLowerCase();
if (strLower in colors) {
return strLower as Color;
}
return null;
}

View File

@@ -1,12 +1,19 @@
import { emit } from '@tauri-apps/api/event';
import { openUrl } from '@tauri-apps/plugin-opener';
import type { InternalEvent, ShowToastRequest } from '@yaakapp-internal/plugins';
import type { UpdateInfo, UpdateResponse, YaakNotification } from '@yaakapp-internal/tauri';
import { openSettings } from '../commands/openSettings';
import { Button } from '../components/core/Button';
import { ButtonInfiniteLoading } from '../components/core/ButtonInfiniteLoading';
import { Icon } from '../components/core/Icon';
import { HStack, VStack } from '../components/core/Stacks';
// Listen for toasts
import { listenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { updateAvailableAtom } from './atoms';
import { stringToColor } from './color';
import { generateId } from './generateId';
import { jotaiStore } from './jotai';
import { showPrompt } from './prompt';
import { invokeCmd } from './tauri';
import { showToast } from './toast';
@@ -37,40 +44,125 @@ export function initGlobalListeners() {
}
});
listenToTauriEvent<{
id: string;
timestamp: string;
message: string;
timeout?: number | null;
action?: null | {
url: string;
label: string;
};
}>('notification', ({ payload }) => {
console.log('Got notification event', payload);
const actionUrl = payload.action?.url;
const actionLabel = payload.action?.label;
const UPDATE_TOAST_ID = 'update-info'; // Share the ID to replace the toast
listenToTauriEvent<string>('update_installed', async ({ payload: version }) => {
showToast({
id: payload.id,
timeout: payload.timeout ?? undefined,
message: payload.message,
onClose: () => {
invokeCmd('cmd_dismiss_notification', { notificationId: payload.id }).catch(console.error);
},
action: ({ hide }) =>
actionLabel && actionUrl ? (
<Button
size="xs"
color="secondary"
className="mr-auto min-w-[5rem]"
onClick={() => {
hide();
return openUrl(actionUrl);
}}
>
{actionLabel}
</Button>
) : null,
id: UPDATE_TOAST_ID,
color: 'primary',
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">Start using the new version now?</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-[5rem]"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => invokeCmd('cmd_restart', {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
});
});
// Listen for update events
listenToTauriEvent<UpdateInfo>(
'update_available',
async ({ payload: { version, replyEventId, downloaded } }) => {
console.log('Received update available event', { replyEventId, version, downloaded });
jotaiStore.set(updateAvailableAtom, { version, downloaded });
// Acknowledge the event, so we don't time out and try the fallback update logic
await emit<UpdateResponse>(replyEventId, { type: 'ack' });
showToast({
id: UPDATE_TOAST_ID,
color: 'info',
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? 'Do you want to install' : 'Download and install'} the update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[10rem]"
loadingChildren={downloaded ? 'Installing...' : 'Downloading...'}
onClick={async () => {
await emit<UpdateResponse>(replyEventId, { type: 'action', action: 'install' });
}}
>
{downloaded ? 'Install Now' : 'Download and Install'}
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await openUrl('https://yaak.app/changelog/' + version);
}}
>
What&apos;s New
</Button>
</HStack>
),
});
},
);
listenToTauriEvent<YaakNotification>('notification', ({ payload }) => {
console.log('Got notification event', payload);
showNotificationToast(payload);
});
}
function showNotificationToast(n: YaakNotification) {
const actionUrl = n.action?.url;
const actionLabel = n.action?.label;
showToast({
id: n.id,
timeout: n.timeout ?? null,
color: stringToColor(n.color) ?? undefined,
message: (
<VStack>
{n.title && <h2 className="font-semibold">{n.title}</h2>}
<p className="text-text-subtle text-sm">{n.message}</p>
</VStack>
),
onClose: () => {
invokeCmd('cmd_dismiss_notification', { notificationId: n.id }).catch(console.error);
},
action: ({ hide }) => {
return actionLabel && actionUrl ? (
<Button
size="xs"
color={stringToColor(n.color) ?? undefined}
className="mr-auto min-w-[5rem]"
rightSlot={<Icon icon="external_link" />}
onClick={() => {
hide();
return openUrl(actionUrl);
}}
>
{actionLabel}
</Button>
) : null;
},
});
}

View File

@@ -28,6 +28,7 @@ type TauriCmd =
| 'cmd_import_data'
| 'cmd_install_plugin'
| 'cmd_metadata'
| 'cmd_restart'
| 'cmd_new_child_window'
| 'cmd_new_main_window'
| 'cmd_plugin_info'

View File

@@ -118,7 +118,7 @@ function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
return {
text: color.lift(0.8).css(),
textSubtle: color.css(),
textSubtle: color.lift(0.8).translucify(0.3).css(),
surface: color.translucify(0.9).css(),
surfaceHighlight: color.translucify(0.8).css(),
border: color.lift(0.3).translucify(0.6).css(),

View File

@@ -21,7 +21,6 @@ export function showToast({
let delay = 0;
if (toastWithSameId) {
console.log('HIDING TOAST', id);
hideToast(toastWithSameId);
// Allow enough time for old toast to animate out
delay = 200;