feat: add beta release channel

This commit is contained in:
Mohamed Boudra
2026-04-20 20:38:56 +07:00
parent 831d990ef7
commit dfc814147a
32 changed files with 442 additions and 256 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.60-rc.3",
"version": "0.1.60-beta.1",
"private": true,
"scripts": {
"start": "APP_VARIANT=development expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.60-rc.3",
"@getpaseo/highlight": "0.1.60-rc.3",
"@getpaseo/server": "0.1.60-rc.3",
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
"@getpaseo/highlight": "0.1.60-beta.1",
"@getpaseo/server": "0.1.60-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35,12 +35,13 @@ export function SegmentedControl<T extends string>({
testID,
}: SegmentedControlProps<T>) {
const { theme } = useUnistyles();
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return (
<View style={[styles.container, style]} testID={testID}>
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
@@ -89,39 +90,46 @@ export function SegmentedControl<T extends string>({
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
alignItems: "stretch",
maxWidth: "100%",
gap: 4,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
},
containerSm: {
padding: 2,
},
containerMd: {
padding: 3,
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: "transparent",
borderRadius: theme.borderRadius.lg,
gap: theme.spacing[1],
},
segmentSm: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
segmentMd: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
segmentSelected: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 1,
},
segmentHover: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
segmentPressed: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
segmentDisabled: {
opacity: theme.opacity[50],
@@ -17,6 +17,8 @@ export interface DesktopAppUpdateInstallResult {
message: string;
}
export type DesktopReleaseChannel = "stable" | "beta";
export interface LocalDaemonUpdateResult {
exitCode: number;
stdout: string;
@@ -69,8 +71,12 @@ export async function getLocalDaemonVersion(): Promise<LocalDaemonVersionResult>
return parseLocalDaemonVersionResult(result);
}
export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update");
export async function checkDesktopAppUpdate({
releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateCheckResult> {
const result = await invokeDesktopCommand<unknown>("check_app_update", { releaseChannel });
if (!isRecord(result)) {
throw new Error("Unexpected response while checking desktop updates.");
}
@@ -85,8 +91,12 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
};
}
export async function installDesktopAppUpdate(): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update");
export async function installDesktopAppUpdate({
releaseChannel,
}: {
releaseChannel: DesktopReleaseChannel;
}): Promise<DesktopAppUpdateInstallResult> {
const result = await invokeDesktopCommand<unknown>("install_app_update", { releaseChannel });
if (!isRecord(result)) {
throw new Error("Unexpected response while installing desktop update.");
}
@@ -7,6 +7,7 @@ import {
type DesktopAppUpdateCheckResult,
type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates";
import { useAppSettings } from "@/hooks/use-settings";
export type DesktopAppUpdateStatus =
| "idle"
@@ -83,6 +84,8 @@ function formatStatusText(input: {
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings } = useAppSettings();
const releaseChannel = settings.releaseChannel;
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -105,7 +108,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null);
try {
const result = await checkDesktopAppUpdate();
const result = await checkDesktopAppUpdate({ releaseChannel });
if (requestVersion !== requestVersionRef.current) {
return result;
}
@@ -140,9 +143,17 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null;
}
},
[isDesktopApp],
[isDesktopApp, releaseChannel],
);
useEffect(() => {
if (!isDesktopApp) {
return;
}
void checkForUpdates({ silent: true });
}, [checkForUpdates, isDesktopApp]);
useEffect(() => {
if (!isDesktopApp || status !== "pending") {
return undefined;
@@ -166,7 +177,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null);
try {
const result = await installDesktopAppUpdate();
const result = await installDesktopAppUpdate({ releaseChannel });
setLastCheckedAt(Date.now());
if (result.installed) {
@@ -186,7 +197,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message);
return null;
}
}, [isDesktopApp]);
}, [isDesktopApp, releaseChannel]);
return {
isDesktopApp,
@@ -40,6 +40,16 @@ describe("use-settings", () => {
expect(result.theme).toBe("auto");
});
it("defaults release channel to stable when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("stable");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
@@ -58,7 +68,24 @@ describe("use-settings", () => {
theme: "light",
manageBuiltInDaemon: false,
sendBehavior: "interrupt",
releaseChannel: "stable",
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
it("loads persisted beta release channel", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
return JSON.stringify({
releaseChannel: "beta",
});
}
return null;
});
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.releaseChannel).toBe("beta");
});
});
+10
View File
@@ -9,19 +9,23 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
export interface AppSettings {
theme: ThemeName | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
releaseChannel: ReleaseChannel;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
releaseChannel: "stable",
};
export interface UseAppSettingsReturn {
@@ -85,6 +89,9 @@ export async function loadSettingsFromStorage(): Promise<AppSettings> {
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
parsed.theme = DEFAULT_APP_SETTINGS.theme;
}
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
}
return { ...DEFAULT_APP_SETTINGS, ...parsed };
}
@@ -115,6 +122,9 @@ function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<App
if (typeof legacy.manageBuiltInDaemon === "boolean") {
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
}
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
result.releaseChannel = legacy.releaseChannel;
}
return result;
}
+65 -37
View File
@@ -281,6 +281,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
}
function DesktopAppUpdateRow() {
const { settings, updateSettings } = useAppSettings();
const {
isDesktopApp,
statusText,
@@ -309,6 +310,13 @@ function DesktopAppUpdateRow() {
void checkForUpdates();
}, [checkForUpdates, isDesktopApp]);
const handleReleaseChannelChange = useCallback(
(releaseChannel: AppSettings["releaseChannel"]) => {
void updateSettings({ releaseChannel });
},
[updateSettings],
);
const handleInstallUpdate = useCallback(() => {
if (!isDesktopApp) {
return;
@@ -337,40 +345,59 @@ function DesktopAppUpdateRow() {
}
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App updates</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Release channel</Text>
<Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
Switch to Beta to get updates sooner and help shape them.
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
</View>
<SegmentedControl
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
value={settings.releaseChannel}
onValueChange={handleReleaseChannelChange}
options={[
{ value: "stable", label: "Stable" },
{ value: "beta", label: "Beta" },
]}
/>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App updates</Text>
<Text style={settingsStyles.rowHint}>{statusText}</Text>
{availableUpdate?.latestVersion ? (
<Text style={settingsStyles.rowHint}>
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
</Text>
) : null}
{errorMessage ? <Text style={styles.aboutErrorText}>{errorMessage}</Text> : null}
</View>
<View style={styles.aboutUpdateActions}>
<Button
variant="outline"
size="sm"
onPress={handleCheckForUpdates}
disabled={isChecking || isInstalling}
>
{isChecking ? "Checking..." : "Check"}
</Button>
<Button
variant="default"
size="sm"
onPress={handleInstallUpdate}
disabled={isChecking || isInstalling || !availableUpdate}
>
{isInstalling
? "Installing..."
: availableUpdate?.latestVersion
? `Update to ${formatVersionWithPrefix(availableUpdate.latestVersion)}`
: "Update"}
</Button>
</View>
</View>
</>
);
}
@@ -858,9 +885,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
layout="desktop"
/>
<View style={desktopStyles.contentPane}>
{detailHeader ? (
<ScreenHeader
left={
<ScreenHeader
borderless={!detailHeader}
left={
detailHeader ? (
<>
<HeaderIconBadge>
<detailHeader.Icon
@@ -873,10 +901,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
</ScreenTitle>
{detailHeader.titleAccessory}
</>
}
leftStyle={desktopStyles.detailLeft}
/>
) : null}
) : null
}
leftStyle={desktopStyles.detailLeft}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom }}
+56 -48
View File
@@ -19,58 +19,66 @@ type RawWindowControlsPadding = {
type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar";
// Module-level cache so hook remounts (e.g., on navigation) don't briefly
// fall back to the default `false` while the async fullscreen check resolves.
// Without this, in fullscreen the sidebar flashes with traffic-light padding
// on first frame and then snaps to 0 once the async read completes.
let cachedIsFullscreen = false;
const fullscreenSubscribers = new Set<(value: boolean) => void>();
let fullscreenSubscriptionStarted = false;
function setCachedFullscreen(value: boolean) {
if (cachedIsFullscreen === value) return;
cachedIsFullscreen = value;
for (const sub of fullscreenSubscribers) {
sub(value);
}
}
function startFullscreenSubscription() {
if (fullscreenSubscriptionStarted) return;
if (isNative || !getIsElectronRuntime()) return;
fullscreenSubscriptionStarted = true;
void (async () => {
const win = getDesktopWindow();
if (!win) return;
if (typeof win.isFullscreen === "function") {
try {
setCachedFullscreen(await win.isFullscreen());
} catch (error) {
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
}
}
if (typeof win.onResized !== "function") return;
try {
await win.onResized(async () => {
if (typeof win.isFullscreen !== "function") return;
try {
setCachedFullscreen(await win.isFullscreen());
} catch (error) {
console.warn("[DesktopWindow] Failed to read fullscreen state", error);
}
});
} catch (error) {
console.warn("[DesktopWindow] Failed to subscribe to resize", error);
}
})();
}
function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen);
useEffect(() => {
if (isNative || !getIsElectronRuntime()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = getDesktopWindow();
if (!win) return;
const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fullscreen);
if (typeof win.onResized !== "function") {
return;
}
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten;
if (disposed) {
runCleanup();
}
}
void setup();
startFullscreenSubscription();
// Sync to any value that resolved between render and effect.
setIsFullscreen(cachedIsFullscreen);
fullscreenSubscribers.add(setIsFullscreen);
return () => {
disposed = true;
runCleanup();
fullscreenSubscribers.delete(setIsFullscreen);
};
}, []);