diff --git a/desktop-ui/src/components/app-layout.tsx b/desktop-ui/src/components/app-layout.tsx index 114f9568..27544fab 100644 --- a/desktop-ui/src/components/app-layout.tsx +++ b/desktop-ui/src/components/app-layout.tsx @@ -9,6 +9,7 @@ import { HttpsTrustNotice } from "./app/https-trust-notice"; import { useProjectCreateHotkey } from "./app/projects/use-project-create-hotkey"; import { useProjectSearchHotkey } from "./app/projects/use-project-search-hotkey"; import { TerminalPanel } from "./app/terminal/terminal-panel"; +import { UpdateNotice } from "./app/update-notice"; import { DesktopAnalytics } from "./shared/desktop-analytics"; import { PageTransition } from "./shared/page-transition"; import { SharedNavigation } from "./shared/shared-navigation"; @@ -43,6 +44,7 @@ function AppLayoutContent() { {!isOpen && !outputOpen ? : null} +
diff --git a/desktop-ui/src/components/app-shell.tsx b/desktop-ui/src/components/app-shell.tsx index 2a9ecafc..da6185d9 100644 --- a/desktop-ui/src/components/app-shell.tsx +++ b/desktop-ui/src/components/app-shell.tsx @@ -9,7 +9,6 @@ import { loadStoredOnboardingCompleted, markStoredOnboardingCompleted, } from "@/lib/util.onboarding-state"; -import { AppUpdateProvider } from "@/providers/app-update-provider"; import { SpinnerIcon } from "./icons"; import { Onboarding } from "./onboarding"; @@ -83,20 +82,13 @@ export const AppShell = ({ children }: AppShellProps) => { markStoredOnboardingCompleted(); }; - return ( - - {needsSetup ? ( - - ) : ( - <>{children} - )} - + return needsSetup ? ( + + ) : ( + <>{children} ); }; diff --git a/desktop-ui/src/components/app/restart-to-update.tsx b/desktop-ui/src/components/app/restart-to-update.tsx new file mode 100644 index 00000000..08914fdb --- /dev/null +++ b/desktop-ui/src/components/app/restart-to-update.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { systemMutations } from "@/lib/queries"; +import { restartCloses } from "@/lib/util.app-update"; +import { useTerminal } from "@/providers/terminal-provider"; + +interface RestartToUpdateProps { + appName: string; + version: string; + size?: "xs" | "sm"; +} + +/** + * `Restart to update`: names the tabs a restart closes, then hands the install to Localify's + * installer and quits. The app never restarts by itself. + */ +export const RestartToUpdate = ({ appName, version, size = "sm" }: RestartToUpdateProps) => { + const { sessions } = useTerminal(); + const restart = useMutation(systemMutations.restartToUpdate()); + const [open, setOpen] = useState(false); + const closes = restartCloses(sessions); + const closesNothing = closes.services.length === 0 && closes.shells.length === 0; + + return ( + <> + + + + + Restart to update? + + {appName} quits, installs {version}, then opens again. + + + + {closesNothing ? ( +

No service or shell tab is open.

+ ) : ( + <> + + + + )} + {restart.error ? ( +

{restart.error.message}

+ ) : null} +
+ + } disabled={restart.isPending}> + Cancel + + + +
+
+ + ); +}; + +const ClosedTabs = ({ label, names }: { label: string; names: string[] }) => { + if (names.length === 0) return null; + + return ( +
+

{label}

+
    + {names.map((name, index) => ( + // Two shells in one checkout share a name. +
  • + {name} +
  • + ))} +
+
+ ); +}; diff --git a/desktop-ui/src/components/app/settings/settings-screen.tsx b/desktop-ui/src/components/app/settings/settings-screen.tsx index 9e316b1d..1ab23270 100644 --- a/desktop-ui/src/components/app/settings/settings-screen.tsx +++ b/desktop-ui/src/components/app/settings/settings-screen.tsx @@ -61,6 +61,7 @@ export const SettingsScreen = () => {
+ @@ -70,7 +71,6 @@ export const SettingsScreen = () => { - {import.meta.env.DEV ? : null}
diff --git a/desktop-ui/src/components/app/settings/settings-update-card.tsx b/desktop-ui/src/components/app/settings/settings-update-card.tsx index a426276f..1f54b249 100644 --- a/desktop-ui/src/components/app/settings/settings-update-card.tsx +++ b/desktop-ui/src/components/app/settings/settings-update-card.tsx @@ -1,95 +1,87 @@ "use client"; -import { motion } from "motion/react"; +import type { ReactNode } from "react"; + +import type { UpdateStatusDto } from "@/types/localify"; import { DownloadIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; -import type { AppUpdateStatus } from "@/providers/app-update-provider"; -import type { AppUpdateInfoDto } from "@/types/localify"; +import { Switch } from "@/components/ui/switch"; +import { describeUpdate } from "@/lib/util.app-update"; + +import { SettingsCard } from "./settings-card"; interface SettingsUpdateCardProps { - status: AppUpdateStatus; - availableUpdate: AppUpdateInfoDto | null; - errorMessage: string | null; - onCheckForUpdates: () => void; - onInstallUpdate: () => void; + appName: string; + /** Null while it is read. */ + status: UpdateStatusDto | null; + /** Unix seconds, for the last check's age. */ + now: number; + error: string | null; + automatic: boolean; + automaticDisabled: boolean; + onAutomaticChange: (automatic: boolean) => void; + checking: boolean; + onCheck: () => void; + /** Shown in place of `Check now` when an update can be installed. */ + restart: ReactNode; } export const SettingsUpdateCard = ({ + appName, status, - availableUpdate, - errorMessage, - onCheckForUpdates, - onInstallUpdate, + now, + error, + automatic, + automaticDisabled, + onAutomaticChange, + checking, + onCheck, + restart, }: SettingsUpdateCardProps) => { - const isChecking = status === "checking"; - const isInstalling = status === "installing"; - const hasUpdate = Boolean(availableUpdate); - const title = hasUpdate - ? `Update ready - v${availableUpdate?.version}` - : status === "upToDate" - ? "You're up to date" - : status === "unconfigured" - ? "Updates unavailable" - : "Desktop updates"; - const description = hasUpdate - ? "A new Localify build is ready to download and install." - : status === "unconfigured" - ? "Auto-updates are not configured for this build." - : "Checks for new Localify builds in the background and on demand."; - - const action = hasUpdate ? ( - - ) : ( - - ); + const summary = status ? describeUpdate(status, appName, now) : null; + const busy = status?.state === "checking" || status?.state === "installing"; return ( -
- -
- - - -
-

- {title} -

-

- {description} + } + title="Install updates automatically" + description={`New versions install while ${appName} is closed. While it's open, they wait for you to restart.`} + trailing={ + + } + > +

+
+

+ {summary?.title ?? "Reading the update status…"} +

+ {summary?.detail ? ( +

+ {summary.detail}

-
- {action} + ) : null}
- - - {errorMessage ? ( -

{errorMessage}

- ) : null} -
+ {summary?.canRestart ? ( + restart + ) : ( + + )} + + {error ?

{error}

: null} + ); }; diff --git a/desktop-ui/src/components/app/settings/settings-update-preview.tsx b/desktop-ui/src/components/app/settings/settings-update-preview.tsx index 92e8202b..a4ab352b 100644 --- a/desktop-ui/src/components/app/settings/settings-update-preview.tsx +++ b/desktop-ui/src/components/app/settings/settings-update-preview.tsx @@ -2,76 +2,64 @@ import { useState } from "react"; +import type { UpdateStatusDto } from "@/types/localify"; + import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import type { AppUpdateStatus } from "@/providers/app-update-provider"; -import type { AppUpdateInfoDto } from "@/types/localify"; import { SettingsUpdateCard } from "./settings-update-card"; -type UpdatePreviewState = { - status: AppUpdateStatus; - label: string; - update: AppUpdateInfoDto | null; - errorMessage: string | null; -}; +const NOW = 1_780_000_000; -const previewUpdate = { - version: "1.4.0", - notes: "Mock release notes for the update card preview.", - pubDate: "2026-04-27T10:00:00Z", -} satisfies AppUpdateInfoDto; +const base: UpdateStatusDto = { + installed: "1.6.0", + available: null, + state: "upToDate", + reason: null, + automatic: true, + lastCheckAt: NOW - 3 * 3600, + canInstall: true, +}; -const previewStates = [ - { - status: "idle", - label: "Idle", - update: null, - errorMessage: null, - }, - { - status: "checking", - label: "Checking", - update: null, - errorMessage: null, - }, - { - status: "available", - label: "Available", - update: previewUpdate, - errorMessage: null, - }, - { - status: "installing", - label: "Installing", - update: previewUpdate, - errorMessage: null, - }, +const previewStates: { label: string; status: UpdateStatusDto }[] = [ + { label: "Never checked", status: { ...base, state: "unknown", lastCheckAt: null } }, + { label: "Up to date", status: base }, + { label: "Checking", status: { ...base, state: "checking" } }, + { label: "Available", status: { ...base, state: "available", available: "1.7.0" } }, { - status: "upToDate", - label: "Up to date", - update: null, - errorMessage: null, + label: "Automatic off", + status: { + ...base, + state: "available", + available: "1.7.0", + automatic: false, + reason: + "Automatic updates are off. Install it with: localify update install, or turn them on with: localify update --auto on", + }, }, { - status: "unconfigured", - label: "Unconfigured", - update: null, - errorMessage: null, + label: "Can't install", + status: { + ...base, + state: "available", + available: "1.7.0", + canInstall: false, + reason: + "/Applications/Localify.app belongs to another account: only its owner can update it.", + }, }, + { label: "Installing", status: { ...base, state: "installing", available: "1.7.0" } }, { - status: "error", - label: "Error", - update: previewUpdate, - errorMessage: "Localify could not download the latest desktop update.", + label: "Failed", + status: { ...base, state: "failed", reason: "The update feed could not be reached." }, }, -] satisfies UpdatePreviewState[]; +]; +/** Mock states for designing the update card without a daemon. */ export const SettingsUpdatePreview = () => { - const [selectedStatus, setSelectedStatus] = useState("checking"); - const selectedState = - previewStates.find((previewState) => previewState.status === selectedStatus) ?? - previewStates[0]; + const [selected, setSelected] = useState("Available"); + const preview = + previewStates.find((previewState) => previewState.label === selected) ?? previewStates[0]; return (
@@ -85,16 +73,11 @@ export const SettingsUpdatePreview = () => {
{previewStates.map((previewState) => ( @@ -102,15 +85,20 @@ export const SettingsUpdatePreview = () => {
{ - setSelectedStatus("checking"); - }} - onInstallUpdate={() => { - setSelectedStatus("installing"); - }} + appName="Localify" + status={preview.status} + now={NOW} + error={null} + automatic={preview.status.automatic} + automaticDisabled={false} + onAutomaticChange={() => {}} + checking={false} + onCheck={() => setSelected("Checking")} + restart={ + + } />
); diff --git a/desktop-ui/src/components/app/settings/settings-updates.tsx b/desktop-ui/src/components/app/settings/settings-updates.tsx index d4c4882f..84e8f199 100644 --- a/desktop-ui/src/components/app/settings/settings-updates.tsx +++ b/desktop-ui/src/components/app/settings/settings-updates.tsx @@ -1,23 +1,50 @@ "use client"; -import { useAppUpdate } from "@/hooks/use-app-update"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { RestartToUpdate } from "@/components/app/restart-to-update"; +import { bootstrapQueries, systemMutations, systemQueries } from "@/lib/queries"; import { SettingsUpdateCard } from "./settings-update-card"; export const SettingsUpdates = () => { - const appUpdate = useAppUpdate(); + const queryClient = useQueryClient(); + const { data: bootstrap } = useQuery(bootstrapQueries.read()); + const status = useQuery(systemQueries.updateStatus()); + const setAutomatic = useMutation(systemMutations.setAutomaticUpdates(queryClient)); + const check = useMutation(systemMutations.checkForUpdates(queryClient)); + + const appName = bootstrap?.environment.appName ?? "Localify"; + const pendingAutomatic = setAutomatic.isPending ? setAutomatic.variables?.automatic : undefined; + const error = check.error?.message ?? status.error?.message ?? null; + + async function handleAutomaticChange(automatic: boolean) { + try { + await setAutomatic.mutateAsync({ automatic }); + } catch (mutationError) { + const message = + mutationError instanceof Error ? mutationError.message : String(mutationError); + toast.error("The update setting didn't change", { description: message }); + } + } return ( { - void appUpdate.checkForUpdates(); - }} - onInstallUpdate={() => { - void appUpdate.installUpdate(); - }} + appName={appName} + status={status.data ?? null} + now={Math.floor(Date.now() / 1000)} + error={error} + automatic={pendingAutomatic ?? status.data?.automatic ?? false} + automaticDisabled={!status.data || setAutomatic.isPending} + onAutomaticChange={(automatic) => void handleAutomaticChange(automatic)} + checking={check.isPending} + onCheck={() => check.mutate()} + restart={ + status.data?.available ? ( + + ) : null + } /> ); }; diff --git a/desktop-ui/src/components/app/update-notice.tsx b/desktop-ui/src/components/app/update-notice.tsx new file mode 100644 index 00000000..e231231b --- /dev/null +++ b/desktop-ui/src/components/app/update-notice.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { XIcon } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { bootstrapQueries, systemQueries } from "@/lib/queries"; + +import { RestartToUpdate } from "./restart-to-update"; + +/** A quiet line while an update waits for the person to restart. Hidden for this run once closed. */ +export const UpdateNotice = () => { + const { data: bootstrap } = useQuery(bootstrapQueries.read()); + const { data: status } = useQuery(systemQueries.updateStatus()); + const [dismissed, setDismissed] = useState(null); + + const version = status?.available; + if (!bootstrap || !version || status.state !== "available" || !status.canInstall) return null; + if (dismissed === version) return null; + + const appName = bootstrap.environment.appName; + + return ( +
+

+ {appName} {version} is ready to install. +

+
+ + +
+
+ ); +}; diff --git a/desktop-ui/src/components/onboarding.tsx b/desktop-ui/src/components/onboarding.tsx index 8658e22b..28a6cc49 100644 --- a/desktop-ui/src/components/onboarding.tsx +++ b/desktop-ui/src/components/onboarding.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion, @@ -30,7 +30,13 @@ import { import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { captureDesktopEvent } from "@/lib/analytics"; -import { bootstrapQueries, configMutations, daemonMutations, systemMutations } from "@/lib/queries"; +import { + bootstrapQueries, + configMutations, + daemonMutations, + systemMutations, + systemQueries, +} from "@/lib/queries"; import { tauriClient } from "@/lib/tauri-client"; import { assertInstallCompleted } from "@/lib/util.install-result"; import { cn } from "@/lib/utils"; @@ -623,6 +629,8 @@ function ActivateStep({ const queryClient = useQueryClient(); const install = useMutation(systemMutations.install(queryClient)); const startDaemon = useMutation(daemonMutations.start(queryClient)); + // On for a new install; an older config that predates the setting keeps it off. + const { data: updateStatus } = useQuery(systemQueries.updateStatus()); const appName = bootstrap.environment.appName; const [state, setState] = useState("idle"); @@ -683,6 +691,14 @@ function ActivateStep({ label: `DNS for ${suffix} domains, without /etc/hosts`, }, { icon: , label: `${appName} runs as you, not as root` }, + ...(updateStatus?.automatic + ? [ + { + icon: , + label: "Updates install themselves, unless you turn them off in Settings", + }, + ] + : []), ...(connectAgents ? [ { diff --git a/desktop-ui/src/hooks/use-app-update.ts b/desktop-ui/src/hooks/use-app-update.ts deleted file mode 100644 index 6235e661..00000000 --- a/desktop-ui/src/hooks/use-app-update.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useContext } from "react"; - -import { AppUpdateContext } from "@/providers/app-update-provider"; - -export function useAppUpdate() { - const context = useContext(AppUpdateContext); - - if (!context) { - throw new Error("useAppUpdate must be used within AppUpdateProvider"); - } - - return context; -} diff --git a/desktop-ui/src/lib/queries/system.queries.ts b/desktop-ui/src/lib/queries/system.queries.ts index b8e64c9c..ffaa737c 100644 --- a/desktop-ui/src/lib/queries/system.queries.ts +++ b/desktop-ui/src/lib/queries/system.queries.ts @@ -16,6 +16,8 @@ import type { SettingsImportResultDto, StepDto, UpdateAutostartRequest, + UpdateSettingsRequest, + UpdateStatusDto, UpdateLauncherPreferencesRequest, UninstallPreviewDto, } from "../../types/localify"; @@ -32,8 +34,12 @@ const systemQueryKeys = { agentMcpStatus: () => [...systemQueryKeys.all(), "agent-mcp-status"] as const, autostartStatus: () => [...systemQueryKeys.all(), "autostart-status"] as const, uninstallPreview: () => [...systemQueryKeys.all(), "uninstall-preview"] as const, + updateStatus: () => [...systemQueryKeys.all(), "update-status"] as const, } as const; +/** The daemon checks every few hours: a slow poll still shows its answer and an install. */ +const UPDATE_STATUS_POLL_MS = 5 * 60_000; + const systemMutationInvalidations = [ daemonQueries.all(), domainQueries.all(), @@ -59,6 +65,12 @@ export const systemQueries = { queryKey: systemQueryKeys.uninstallPreview(), queryFn: () => tauriClient.getUninstallPreview(), }), + updateStatus: () => + queryOptions({ + queryKey: systemQueryKeys.updateStatus(), + queryFn: () => tauriClient.getUpdateStatus(), + refetchInterval: UPDATE_STATUS_POLL_MS, + }), } as const; export const systemMutations = { @@ -114,6 +126,23 @@ export const systemMutations = { mutationFn: (request) => tauriClient.setAutostartEnabled(request), onSuccess: async () => invalidateBootstrapAnd(queryClient, systemMutationInvalidations), }), + setAutomaticUpdates: (queryClient: QueryClient) => + mutationOptions({ + mutationFn: (request) => tauriClient.setAutomaticUpdates(request), + onSuccess: (status) => queryClient.setQueryData(systemQueryKeys.updateStatus(), status), + }), + checkForUpdates: (queryClient: QueryClient) => + mutationOptions({ + mutationFn: () => tauriClient.checkForUpdates(), + onSuccess: (status) => queryClient.setQueryData(systemQueryKeys.updateStatus(), status), + // A failed check is in the status too. + onError: async () => + queryClient.invalidateQueries({ queryKey: systemQueryKeys.updateStatus() }), + }), + restartToUpdate: () => + mutationOptions({ + mutationFn: () => tauriClient.restartToUpdate(), + }), uninstall: (queryClient: QueryClient) => mutationOptions({ mutationFn: () => tauriClient.uninstallLocalify(), diff --git a/desktop-ui/src/lib/tauri-client.ts b/desktop-ui/src/lib/tauri-client.ts index b2f29171..7f1106ea 100644 --- a/desktop-ui/src/lib/tauri-client.ts +++ b/desktop-ui/src/lib/tauri-client.ts @@ -11,7 +11,6 @@ import type { AppConfigDto, AutostartStatusDto, DeepLinkPayloadDto, - AppUpdateCheckDto, BootstrapDto, CheckPublicUrlSlugRequest, CliAccessDto, @@ -109,6 +108,8 @@ import type { ShellEnvironmentDto, TerminalWriteRequest, UpdateAutostartRequest, + UpdateSettingsRequest, + UpdateStatusDto, UnregisterDomainResultDto, UpdateDomainRequest, UpdateLauncherPreferencesRequest, @@ -116,7 +117,10 @@ import type { } from "../types/localify"; import { addProjectServiceRequestSchema } from "./validators/validator.add-project-service"; -import { updateAutostartRequestSchema } from "./validators/validator.autostart"; +import { + updateAutostartRequestSchema, + updateSettingsRequestSchema, +} from "./validators/validator.autostart"; import { checkPublicUrlSlugRequestSchema, createPublicUrlRequestSchema, @@ -220,8 +224,11 @@ export interface DesktopClient { getBootstrap(): Promise; getAutostartStatus(): Promise; setAutostartEnabled(request: UpdateAutostartRequest): Promise; - checkAppUpdate(): Promise; - installAppUpdate(): Promise; + getUpdateStatus(): Promise; + setAutomaticUpdates(request: UpdateSettingsRequest): Promise; + checkForUpdates(): Promise; + /** Hands the install off and quits; rejects, and stays open, when it can't. */ + restartToUpdate(): Promise; readInstallAttributionClipboard(): Promise; consumePendingDeepLink(): Promise; getConfig(): Promise; @@ -358,8 +365,13 @@ export function createDesktopClient(): DesktopClient { tauriInvoke("set_autostart_enabled", { request: updateAutostartRequestSchema.parse(request), }), - checkAppUpdate: () => tauriInvoke("check_app_update"), - installAppUpdate: () => tauriInvoke("install_app_update"), + getUpdateStatus: () => tauriInvoke("get_update_status"), + setAutomaticUpdates: (request) => + tauriInvoke("set_automatic_updates", { + request: updateSettingsRequestSchema.parse(request), + }), + checkForUpdates: () => tauriInvoke("check_for_updates"), + restartToUpdate: () => tauriInvoke("restart_to_update"), readInstallAttributionClipboard: () => tauriInvoke("read_install_attribution_clipboard"), consumePendingDeepLink: () => tauriInvoke("consume_pending_deep_link"), getConfig: () => tauriInvoke("get_config"), diff --git a/desktop-ui/src/lib/util.app-update.test.ts b/desktop-ui/src/lib/util.app-update.test.ts new file mode 100644 index 00000000..19260345 --- /dev/null +++ b/desktop-ui/src/lib/util.app-update.test.ts @@ -0,0 +1,164 @@ +/// +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { TerminalSessionDto, UpdateStatusDto } from "../types/localify.ts"; + +import { describeUpdate, lastCheckLine, restartCloses } from "./util.app-update.ts"; + +function tab( + id: string, + options: { + service?: string; + environment?: string; + status?: TerminalSessionDto["status"]; + ended?: boolean; + } = {}, +): TerminalSessionDto { + const environment = options.environment ?? "primary"; + return { + id, + title: options.service ?? "zsh", + workingDirectory: "/repo", + target: { + kind: options.service ? "service" : "project", + projectId: "shop", + projectName: "Shop", + environmentSlug: environment, + environmentName: environment === "primary" ? "main" : environment, + serviceId: options.service ?? null, + serviceName: options.service ?? null, + }, + status: options.status ?? "running", + run: options.service + ? { + runId: `run-${id}`, + profile: null, + ended: options.ended + ? { code: 0, registered: true, stopped: true, at: 1, error: null } + : null, + } + : null, + }; +} + +test("a restart lists the running services and open shells by name", () => { + const closes = restartCloses([ + tab("1", { service: "web" }), + tab("2", { service: "api", environment: "feat-cart" }), + tab("3"), + tab("4", { environment: "feat-cart" }), + ]); + + assert.deepEqual(closes, { + services: ["web in Shop", "api in Shop (feat-cart)"], + shells: ["Shell in Shop", "Shell in Shop (feat-cart)"], + }); +}); + +test("tabs that already ended are not listed", () => { + const closes = restartCloses([ + tab("1", { service: "web", ended: true }), + tab("2", { service: "api", status: "exited" }), + tab("3", { status: "exited" }), + ]); + + assert.deepEqual(closes, { services: [], shells: [] }); +}); + +function status(overrides: Partial): UpdateStatusDto { + return { + installed: "1.6.0", + available: null, + state: "upToDate", + reason: null, + automatic: true, + lastCheckAt: 1_000, + canInstall: true, + ...overrides, + }; +} + +test("an available update offers a restart only where it can install", () => { + const ready = describeUpdate( + status({ state: "available", available: "1.7.0" }), + "Localify", + 1_000, + ); + assert.deepEqual(ready, { + title: "Localify 1.7.0 is available", + detail: "Restart to update when you're ready.", + canRestart: true, + }); + + const devBuild = describeUpdate( + status({ + state: "available", + available: "1.7.0", + canInstall: false, + reason: "This build of Localify is a development build: update it by hand.", + }), + "Localify", + 1_000, + ); + assert.equal(devBuild.canRestart, false); + assert.equal( + devBuild.detail, + "This build of Localify is a development build: update it by hand.", + ); +}); + +test("an install that waits says why and still offers the restart", () => { + const summary = describeUpdate( + status({ + state: "available", + available: "1.7.0", + automatic: false, + reason: "Automatic updates are off.", + }), + "Localify", + 1_000, + ); + + assert.equal(summary.detail, "Automatic updates are off."); + assert.equal(summary.canRestart, true); +}); + +test("the other states read as sentences", () => { + assert.deepEqual(describeUpdate(status({}), "Localify", 1_000 + 7_200), { + title: "Localify 1.6.0 is up to date", + detail: "Last checked 2 hours ago.", + canRestart: false, + }); + assert.equal( + describeUpdate(status({ state: "failed", reason: "offline" }), "Localify", 1_000).detail, + "offline", + ); + assert.equal( + describeUpdate(status({ state: "installing", available: "1.7.0" }), "Localify", 1_000).title, + "Installing Localify 1.7.0…", + ); + assert.deepEqual( + describeUpdate(status({ state: "unknown", lastCheckAt: null }), "Localify", 1_000), + { title: "Localify 1.6.0", detail: "Not checked yet.", canRestart: false }, + ); +}); + +test("a previous version the last install couldn't move says where it is", () => { + const stranded = "The version the last update replaced could not be moved to previous/."; + assert.equal(describeUpdate(status({ reason: stranded }), "Localify", 1_000).detail, stranded); + assert.equal( + describeUpdate(status({ state: "unknown", reason: stranded }), "Localify", 1_000).detail, + stranded, + ); +}); + +test("the last check reads in the largest whole unit", () => { + assert.equal(lastCheckLine(null, 100), "Not checked yet."); + assert.equal(lastCheckLine(100, 130), "Last checked just now."); + assert.equal(lastCheckLine(100, 160), "Last checked 1 minute ago."); + assert.equal(lastCheckLine(0, 59 * 60), "Last checked 59 minutes ago."); + assert.equal(lastCheckLine(0, 3 * 86_400), "Last checked 3 days ago."); + // A clock that went back reads as now. + assert.equal(lastCheckLine(500, 100), "Last checked just now."); +}); diff --git a/desktop-ui/src/lib/util.app-update.ts b/desktop-ui/src/lib/util.app-update.ts new file mode 100644 index 00000000..ce312aed --- /dev/null +++ b/desktop-ui/src/lib/util.app-update.ts @@ -0,0 +1,101 @@ +import type { TerminalSessionDto, UpdateStatusDto } from "../types/localify.ts"; + +/** + * What `Restart to update` closes, by name: the service runs and shells the app's tabs hold. + * Services started in the background belong to the daemon and keep running. + */ +export interface RestartCloses { + services: string[]; + shells: string[]; +} + +export function restartCloses(sessions: TerminalSessionDto[]): RestartCloses { + const services: string[] = []; + const shells: string[] = []; + + for (const session of sessions) { + if (session.status !== "running") continue; + const place = placeOf(session); + if (!session.run) { + shells.push(`Shell in ${place}`); + } else if (!session.run.ended) { + services.push(`${session.target.serviceName ?? "Service"} in ${place}`); + } + } + + return { services, shells }; +} + +// A worktree's tab names its checkout; the primary one needs no label. +function placeOf(session: TerminalSessionDto) { + const { environmentName, environmentSlug, projectName } = session.target; + return environmentSlug === "primary" ? projectName : `${projectName} (${environmentName})`; +} + +export interface UpdateSummary { + title: string; + /** + * Why it waits, why the check failed, where the bundle the last install replaced was left when + * it couldn't go to `previous/`, or when the last check ran. + */ + detail: string | null; + /** Offer `Restart to update`. */ + canRestart: boolean; +} + +/** The status in words, for Settings. `now` is in Unix seconds. */ +export function describeUpdate( + status: UpdateStatusDto, + appName: string, + now: number, +): UpdateSummary { + const lastCheck = lastCheckLine(status.lastCheckAt, now); + const next = status.available ? `${appName} ${status.available}` : null; + + switch (status.state) { + case "available": { + const canRestart = status.canInstall && status.available !== null; + return { + title: next ? `${next} is available` : "An update is available", + detail: status.reason ?? (canRestart ? "Restart to update when you're ready." : lastCheck), + canRestart, + }; + } + case "checking": + return { title: "Checking for updates…", detail: lastCheck, canRestart: false }; + case "installing": + return { + title: `Installing ${next ?? "an update"}…`, + detail: null, + canRestart: false, + }; + case "failed": + return { title: "The last check failed", detail: status.reason, canRestart: false }; + case "upToDate": + return { + title: `${appName} ${status.installed} is up to date`, + detail: status.reason ?? lastCheck, + canRestart: false, + }; + case "unknown": + return { + title: `${appName} ${status.installed}`, + detail: status.reason ?? lastCheck, + canRestart: false, + }; + } +} + +export function lastCheckLine(lastCheckAt: number | null, now: number): string { + if (lastCheckAt === null) return "Not checked yet."; + const minutes = Math.floor(Math.max(0, now - lastCheckAt) / 60); + if (minutes < 1) return "Last checked just now."; + if (minutes < 60) return `Last checked ${plural(minutes, "minute")} ago.`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Last checked ${plural(hours, "hour")} ago.`; + return `Last checked ${plural(Math.floor(hours / 24), "day")} ago.`; +} + +function plural(count: number, unit: string) { + return `${count} ${unit}${count === 1 ? "" : "s"}`; +} diff --git a/desktop-ui/src/lib/validators/validator.autostart.ts b/desktop-ui/src/lib/validators/validator.autostart.ts index c55a4ddb..5f0c4d42 100644 --- a/desktop-ui/src/lib/validators/validator.autostart.ts +++ b/desktop-ui/src/lib/validators/validator.autostart.ts @@ -5,3 +5,7 @@ export const updateAutostartRequestSchema = z.object({ }); export type UpdateAutostartInput = z.infer; + +export const updateSettingsRequestSchema = z.object({ + automatic: z.boolean(), +}); diff --git a/desktop-ui/src/providers/app-update-provider.tsx b/desktop-ui/src/providers/app-update-provider.tsx deleted file mode 100644 index 0b4d55ec..00000000 --- a/desktop-ui/src/providers/app-update-provider.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { confirm, message } from "@tauri-apps/plugin-dialog"; -import { - createContext, - type PropsWithChildren, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, -} from "react"; - -import { tauriClient } from "@/lib/tauri-client"; -import type { AppUpdateInfoDto } from "@/types/localify"; - -export type AppUpdateStatus = - | "idle" - | "checking" - | "available" - | "installing" - | "upToDate" - | "unconfigured" - | "error"; - -interface AppUpdateState { - status: AppUpdateStatus; - availableUpdate: AppUpdateInfoDto | null; - errorMessage: string | null; -} - -interface AppUpdateContextValue extends AppUpdateState { - isChecking: boolean; - isInstalling: boolean; - checkForUpdates: () => Promise; - installUpdate: () => Promise; -} - -interface AppUpdateProviderProps extends PropsWithChildren { - desktopName: string; - enabled: boolean; -} - -const defaultState: AppUpdateState = { - status: "idle", - availableUpdate: null, - errorMessage: null, -}; - -const UPDATE_CHECK_TIMEOUT_MS = 30_000; -const UPDATE_INSTALL_TIMEOUT_MS = 5 * 60_000; - -export const AppUpdateContext = createContext(null); - -async function withTimeout( - promise: Promise, - timeoutMs: number, - message: string, -): Promise { - let timeoutId: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); - }); - - try { - return await Promise.race([promise, timeout]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } -} - -function resolveErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) { - return error.message; - } - - return "Localify could not download the latest desktop update."; -} - -async function confirmUpdateInstall( - desktopName: string, - update: AppUpdateInfoDto, -): Promise { - return confirm( - `Version ${update.version} is available.\n\nDownload and install it now? ${desktopName} will restart automatically when the install finishes.`, - { - title: "Update Available", - kind: "info", - okLabel: "Download", - cancelLabel: "Not now", - }, - ); -} - -export const AppUpdateProvider = ({ - children, - desktopName, - enabled, -}: AppUpdateProviderProps) => { - const [state, setState] = useState(defaultState); - const backgroundCheckStartedRef = useRef(false); - const checkInFlightRef = useRef(false); - const installInFlightRef = useRef(false); - - const installUpdateEvent = useEffectEvent( - async (update: AppUpdateInfoDto | null, source: "background" | "manual") => { - if (!update || installInFlightRef.current) { - return; - } - - installInFlightRef.current = true; - setState({ - status: "installing", - availableUpdate: update, - errorMessage: null, - }); - - try { - await withTimeout( - tauriClient.installAppUpdate(), - UPDATE_INSTALL_TIMEOUT_MS, - "Update download timed out. Check your connection and try again.", - ); - setState({ - status: "upToDate", - availableUpdate: null, - errorMessage: null, - }); - } catch (error) { - const errorMessage = resolveErrorMessage(error); - - setState({ - status: "error", - availableUpdate: update, - errorMessage, - }); - - await message(errorMessage, { - title: source === "manual" ? "Update Failed" : "Update Install Failed", - kind: "error", - }); - } finally { - installInFlightRef.current = false; - } - }, - ); - - const checkForUpdatesEvent = useEffectEvent(async (source: "background" | "manual") => { - if (!enabled || checkInFlightRef.current || installInFlightRef.current) { - return; - } - - checkInFlightRef.current = true; - setState((current) => ({ - ...current, - status: "checking", - errorMessage: null, - })); - - try { - const updateCheck = await withTimeout( - tauriClient.checkAppUpdate(), - UPDATE_CHECK_TIMEOUT_MS, - "Update check timed out. Try again in a moment.", - ); - - if (!updateCheck.configured) { - setState({ - status: "unconfigured", - availableUpdate: null, - errorMessage: null, - }); - - if (source === "manual") { - await message("Desktop updates are not configured for this build.", { - title: "Updates Unavailable", - kind: "info", - }); - } - - return; - } - - if (!updateCheck.update) { - setState({ - status: "upToDate", - availableUpdate: null, - errorMessage: null, - }); - - if (source === "manual") { - await message(`${desktopName} is already up to date.`, { - title: "You're Up to Date", - kind: "info", - }); - } - - return; - } - - setState({ - status: "available", - availableUpdate: updateCheck.update, - errorMessage: null, - }); - - const confirmed = await confirmUpdateInstall(desktopName, updateCheck.update); - if (!confirmed) { - return; - } - - await installUpdateEvent(updateCheck.update, source); - } catch (error) { - const errorMessage = resolveErrorMessage(error); - - setState((current) => ({ - status: "error", - availableUpdate: current.availableUpdate, - errorMessage, - })); - - if (source === "manual") { - await message(errorMessage, { - title: "Update Check Failed", - kind: "error", - }); - } - } finally { - checkInFlightRef.current = false; - } - }); - - useEffect(() => { - if (!enabled || backgroundCheckStartedRef.current) { - return; - } - - backgroundCheckStartedRef.current = true; - void checkForUpdatesEvent("background"); - }, [enabled]); - - const value = useMemo( - () => ({ - ...state, - isChecking: state.status === "checking", - isInstalling: state.status === "installing", - checkForUpdates: async () => { - await checkForUpdatesEvent("manual"); - }, - installUpdate: async () => { - await installUpdateEvent(state.availableUpdate, "manual"); - }, - }), - [state], - ); - - return {children}; -}; diff --git a/desktop-ui/src/types/localify.ts b/desktop-ui/src/types/localify.ts index 0e523203..943988af 100644 --- a/desktop-ui/src/types/localify.ts +++ b/desktop-ui/src/types/localify.ts @@ -539,15 +539,30 @@ export interface SettingsImportResultDto { daemonReloadError: string | null; } -export interface AppUpdateInfoDto { - version: string; - notes: string | null; - pubDate: string | null; +/** `state` of the daemon's `update_status` (docs/desktop.md, "Automatic updates"). */ +export type UpdatePhase = + | "unknown" + | "upToDate" + | "available" + | "checking" + | "installing" + | "failed"; + +export interface UpdateStatusDto { + installed: string; + available: string | null; + state: UpdatePhase; + /** Why an available update waits, or why the last check failed. */ + reason: string | null; + automatic: boolean; + /** Unix seconds. */ + lastCheckAt: number | null; + /** False when this copy can only be updated by hand: `reason` says why. */ + canInstall: boolean; } -export interface AppUpdateCheckDto { - configured: boolean; - update: AppUpdateInfoDto | null; +export interface UpdateSettingsRequest { + automatic: boolean; } export type DeepLinkRouteDto = "projects" | "logs" | "launchService"; diff --git a/docs/adr/0034-machines-update-themselves.md b/docs/adr/0034-machines-update-themselves.md index cb24a018..1c2ecbdd 100644 --- a/docs/adr/0034-machines-update-themselves.md +++ b/docs/adr/0034-machines-update-themselves.md @@ -4,7 +4,7 @@ A Mac with Localify can install new versions by itself, and a machine that sees **Who installs.** - **With the app closed, the daemon installs.** That covers a runner nobody sits at. The daemon already runs as the installing user, not as root, and installs only where that user can replace the app. This feature adds no privileged process. -- **With the app open, the update waits for the person.** The app downloads in the background and offers `Restart to update`. On that click, it hands off to Localify's own installer and quits, and the installer opens the new version. It never relaunches by itself: a relaunch closes service tabs and shell tabs, and an idle check cannot prove that no one is using them. Tauri's own replacement is not used, so there is one installer with one set of rules. +- **With the app open, the update waits for the person.** The app shows the update the daemon found and offers `Restart to update`. On that click, it hands off to Localify's own installer and quits, and the installer opens the new version. It never relaunches by itself: a relaunch closes service tabs and shell tabs, and an idle check cannot prove that no one is using them. Tauri's own replacement is not used, so there is one installer with one set of rules. - **One installer at a time.** The app holds a lock for its lifetime, and the installer also looks for any process running from the bundle, so it never replaces a bundle in use. That process check covers older apps that take no lock. A person dragging a DMG in at the very same moment is not supported. **Paired machines only trigger a check.** A paired machine's version comes from the relay's presence, which is not authenticated, so it is a hint and nothing more. When it is newer than this machine's, this machine checks the official manifest early, within a machine-wide cooldown. A hint never chooses a build or proves that one exists. Only a release signed with Localify's updater key, whose bundle is signed by Localify's Developer ID, is ever installed. diff --git a/docs/desktop.md b/docs/desktop.md index 58b44165..99bce5e4 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -122,18 +122,18 @@ daemon started another way, such as a dev build, is left alone. A Mac can install new versions by itself (ADR 0034). -- **Setting:** `[updates] automatic` in the config. It starts on for new installs and off for installs that predate it. Settings → General has `Install updates automatically`, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it. An unattended install requires `automatic` to be on just before it replaces anything; an install a person confirms works whether it is on or off. +- **Setting:** `[updates] automatic` in the config. It starts on only for a new install: no config, no old `/etc/localify` folder, and nothing an earlier install leaves behind (its root CA and certificates, the daemon's pid file or log, the launchd service, the DNS resolver). A place that can't be checked counts as there. On a new install the app writes the config, with it on, as it starts, before its welcome can save a setting; `localify install` writes it on too when it finds no config and nothing else. Anything else starts off. A config that is missing or can't be read counts as off. Moving an old `/etc/localify` config into the user's folder keeps that config's setting, off when it has none, even when the welcome already wrote a config there. Settings has `Install updates automatically`, right after Startup, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it. An unattended install requires `automatic` to be on just before it replaces anything; an install a person confirms works whether it is on or off. - **Checks:** 2 minutes after the daemon starts, then every 6 hours plus or minus 30 minutes. Also when `remote_machines` shows a paired machine on a newer version: such a version comes from the relay's presence and is only a hint. Every check, scheduled, triggered or manual, goes through one coalesced operation with a machine-wide cooldown of 10 minutes, doubled after each failure up to 6 hours. The last check's time is persisted, so a restart doesn't reset it. The daemon also downloads at most once every 6 hours until an install succeeds: the time of its last download is persisted before the download starts, so neither a restart nor a hint repeats it. A person's `localify update install` doesn't wait for that cooldown. A check reads the manifest the app's updater reads (`latest.json`, from the build's updater endpoint) over HTTPS. That manifest is discovery, not authority. - **Trust:** the archive's minisign signature is checked against the updater public key built into Localify before anything is extracted. Only a version strictly newer than both the installed bundle's `CFBundleShortVersionString` and the highest version this machine ever installed is accepted, compared by semver precedence, so build metadata (`+...`) never makes a version newer. A build that failed here before is skipped until the next release: it is recorded by its archive's SHA-256 and by its release identity (the manifest's version and signature), which is checked before anything is downloaded. Only failures of the archive are recorded: a bad signature, one it can't be read from, one that breaks the extraction rules, or a bundle whose version isn't the manifest's. Nothing else is, since it can be this machine's fault: a full disk, a write error, or a `codesign` or `plutil` that refuses or couldn't run, as they also do for local reasons such as too many open files. Those are retried at the next check. The tools are given the updates folder's resolved path, the one whose every folder was checked, never a spelling through a symlink. After extraction, the bundle's version must equal the manifest's. `codesign --verify --deep --strict` must pass with a requirement anchored to Apple and pinned to Localify's team ID and bundle identifier, covering the daemon's executable too. Freshness is not guaranteed: a compromised feed could withhold an update or offer an older valid one to an older client, and this version accepts that. - **Extraction:** into a new private directory on the destination's volume, made by the installer. No other account may be able to change any folder above it: from `/` down to the updates folder, each must be owned by this account or root, have no group or other write in its mode, and have no ACL entry that lets another account add, delete or rename what it holds, or change its ACL or owner (`add_file`, `add_subdirectory`, `delete_child`, `delete`, `writesecurity`, `chown`). Denials, entries for this account, and entries only passed on to new folders don't count. Otherwise the installer stops before extracting, and the status's `reason` names the folder: whoever can change it could rename a folder below and put their own tree where the staged bundle is. Each folder is opened from the one above it, never through a symlink, and the new directory stays open until the swap. The updates folder, `staging/` and the new directory must be owned by this account; the installer removes write access for anyone else from their mode, clears their ACLs (as `chmod -N` does, so an inherited ACL can't let another account in), and checks the new directory is empty. The archive may hold exactly one `Localify.app`, with no absolute or `..` paths, no hard links, no device or FIFO entries, no setuid or setgid bits, no data in entries that aren't files, and only relative symlinks that stay inside the bundle. Every path is created one component at a time from a directory descriptor, never following a symlink, and symlinks are made only after every file and folder, so a name that differs only in case on a case-insensitive volume can't lead outside. The archive is read in tar's raw mode: every header counts toward 20,000 entries, including GNU long-name and PAX headers, whose contents are capped at 1 MiB together. Of PAX records, only `path` and `linkpath` are used; `size`, sparse records and global headers are refused. The files are capped at 1 GiB, and the decompressed stream at that plus the headers. Quarantine is not added and never cleared; the archive is notarized and stapled. -- **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. -- **One installer:** the `localify` binary's own installer, in the core, enforces every rule here. The daemon runs it unattended, and `localify update install` runs it when a person asks. Tauri's replacement (`Update::install`) is never used. The app's updater only checks and downloads; installing goes through this installer. -- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. While the installer holds the update lock, an app that starts waits for it, then starts on whatever is installed. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. Two cases are not "in use": the bundle's own `localify`, which the daemon and the CLI run from and which outlive a replacement, and a process whose executable was deleted, which can't be running the bundle's files on disk. +- **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. The app applies the same rules to its own bundle, since `Restart to update` hands off to that bundle's CLI: a copy opened from a DMG or Downloads offers no `Restart to update` and says why, even when the daemon's bundle could be replaced. It checks again right before it hands off and quits. +- **One installer:** the `localify` binary's own installer, in the core, enforces every rule here. The daemon runs it unattended, and `localify update install` runs it when a person asks. The app has no updater of its own: it reads `update_status`, runs the same coalesced check for `Check now` and the `Check for Updates…` menu, and installs through this installer. Tauri's updater plugin is not used; the bundler still signs the updater archive with the key in `tauri.conf.json`. +- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. An app that finds `app.lock` held while `update.lock` is held too waits for the install, saying "Updating Localify…" after a second. When `app.lock` stays held without `update.lock`, another copy of the app holds it, and this one hands its launch over without waiting. It tries `app.lock` again for a tenth of a second first, since an installer lets go of `update.lock` an instant before `app.lock`, and the daemon's check takes `app.lock` alone for an instant. If no other copy takes the launch, this one is the copy that runs, and it takes the lock then. Whatever it found, before its window opens the app checks whether its bundle now holds a newer version than its own, and runs that executable instead. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. Two cases are not "in use": the bundle's own `localify`, which the daemon and the CLI run from and which outlive a replacement, and a process whose executable was deleted, which can't be running the bundle's files on disk. - **When the daemon installs:** only when both checks pass. Otherwise it reports the update as available and tries again at the next check. - **Swap:** under an exclusive `flock` on `~/Library/Application Support/Localify/update.lock`, a file that is never deleted, in the bundle owner's account, at a path that doesn't depend on the configurable data dir. Only the owner's account installs. The installer checks again that the destination is the same inode and version it verified, and that the staged bundle still has the version and passes the same `codesign` check, and fails without touching it when the volume lacks `RENAME_SWAP`. `codesign` and `plutil` need a path, so right before and right after each of them the installer checks that the path still leads to the bundle in the staging directory it holds open. It then exchanges the staged bundle and the installed one with `renameatx_np`, naming the staged one from that open directory and the installed one from its open folder, so nothing that renames a folder above them changes what is swapped. The old bundle moves to `~/Library/Application Support/Localify/previous/.app`, which keeps one previous version; the copy kept before is removed only once the new one is in `previous/`. If the move fails, the old bundle stays in its staging directory, and the install's output and the status's `reason` say where, until it is deleted. The daemon restarts on the new binary, as it already does after any replacement. A destination that changed before that check makes the installer stop and leave it as it is. A person replacing the bundle at the very same moment, from a DMG for example, is not supported. -- **With the app open:** the app checks and downloads in the background when `automatic` is on, and shows `Restart to update`. Restarting lists what it will close (service tabs and shell tabs). On the person's click, the app starts `localify update install --relaunch` detached in its own session, then quits. That process waits for `app.lock`, installs, and opens the new app in the same login session. The app never relaunches by itself. -- **Status:** `update_status` on the management socket answers `{installed, available, state, reason, automatic}`, and so does `localify update --status` (JSON when stdout is not a terminal; read locally when the daemon isn't running). `state` is `unknown` (never checked), `upToDate`, `available`, `checking`, `installing` or `failed` (the last check failed, or `updates.json` can't be trusted). `reason` says why an available update waits, or why the check failed. That is what the app and the CLI read, so nothing depends on a notification arriving. -- **Files:** `~/Library/Application Support/Localify/` holds `app.lock`, `update.lock`, `check.lock` (held during a check, so checks coalesce), `updates.json` (the highest version installed, the digests and releases that failed, the last check, its failures and what it read, the last unattended download), `staging/`, `previous/` and `updater.log`. A missing `updates.json` is a first use. One that can't be read or holds something invalid, such as a highest version that isn't a version, stops every check and install, with a `failed` status that names the file: reading it as new would forget the oldest version allowed and the releases that failed. To start again, delete it; the installed version is then the oldest one allowed. `localify::infrastructure::updates::app_lock_path()` gives the app its lock's path. +- **With the app open:** the app shows an available update in Settings → `Install updates automatically` and as a banner, with `Restart to update` when this copy may be replaced. Restarting lists what it will close by name: the running service tabs and the open shell tabs. On the person's click, the app starts `localify update install --relaunch` from its bundle's own CLI, detached in its own session (`setsid`) with stdin on `/dev/null`, then quits, which releases `app.lock`. If that process can't start, the app says why and stays open. The installer downloads after the app quits. That process waits for `app.lock`, installs, and opens the new app in the same login session. The app never relaunches by itself. +- **Status:** `update_status` on the management socket answers `{installed, available, state, reason, automatic, lastCheckAt, canInstall}`, and so does `localify update --status` (JSON when stdout is not a terminal; read locally when the daemon isn't running). `state` is `unknown` (never checked), `upToDate`, `available`, `checking`, `installing` or `failed` (the last check failed, or `updates.json` can't be trusted). `reason` says why an available update waits, why the check failed, or where a previous bundle was left. `lastCheckAt` is the last check's time in Unix seconds. `canInstall` is false when this copy can only be updated by hand (a dev build, a bundle in another place or owned by another account, Linux) or `updates.json` can't be trusted; `reason` then says why, and the app offers no `Restart to update`. The app also sets it false when its own bundle fails those rules, with that reason when an update waits. With the daemon down, the app asks the bundle's CLI for the same status. That is what the app and the CLI read, so nothing depends on a notification arriving. +- **Files:** `~/Library/Application Support/Localify/` holds `app.lock`, `update.lock`, `check.lock` (held during a check, so checks coalesce), `updates.json` (the highest version installed, the digests and releases that failed, the last check, its failures and what it read, the last unattended download), `staging/`, `previous/` and `updater.log`, the one updater log. A missing `updates.json` is a first use. One that can't be read or holds something invalid, such as a highest version that isn't a version, stops every check and install, with a `failed` status that names the file: reading it as new would forget the oldest version allowed and the releases that failed. To start again, delete it; the installed version is then the oldest one allowed. `localify::infrastructure::updates::app_lock::take_app_lock` is how the app takes its lock. - **Recovery:** no automatic rollback. If a new version misbehaves, `~/Library/Application Support/Localify/previous/` holds the last one (or the staging directory the status names, when it couldn't be moved there): quit Localify, put that bundle back in place of the installed one, and turn `automatic` off until a fixed release. - **Linux:** no unattended install, because the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version says "Update available: run the install command again" in `localify status` and in the app. It reads only the manifest's `version`; release builds get the manifest's address from `LOCALIFY_UPDATER_BASE_URL`, as the app does. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f76b5b85..1b109c96 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -107,15 +107,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arc-swap" version = "1.9.1" @@ -1112,17 +1103,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "0.99.20" @@ -2743,7 +2723,6 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", - "tauri-plugin-updater", ] [[package]] @@ -3150,18 +3129,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-osa-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-app-kit", - "objc2-foundation", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3239,12 +3206,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "option-ext" version = "0.2.0" @@ -3271,20 +3232,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "osakit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" -dependencies = [ - "objc2", - "objc2-foundation", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "pango" version = "0.18.3" @@ -4018,20 +3965,15 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4137,25 +4079,12 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -4165,33 +4094,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.11" @@ -4225,15 +4127,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.8.22" @@ -4291,29 +4184,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.24.0" @@ -4907,7 +4777,6 @@ checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", - "xattr", ] [[package]] @@ -5197,39 +5066,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "tauri-plugin-updater" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" -dependencies = [ - "base64 0.22.1", - "dirs 6.0.0", - "flate2", - "futures-util", - "http", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest", - "rustls", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "url", - "windows-sys 0.60.2", - "zip", -] - [[package]] name = "tauri-runtime" version = "2.10.1" @@ -6224,15 +6060,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.7" @@ -7014,16 +6841,6 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "xkeysym" version = "0.2.1" @@ -7203,18 +7020,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.14.0", - "memchr", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fdeb8e9b..83f6ef7d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,6 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-global-shortcut = "2.3.1" -tauri-plugin-updater = "2.10.1" [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] tauri-plugin-autostart = "2.5.1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5b43d15a..dbef647d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,16 +29,11 @@ use localify::desktop::dto::{ StartCloudAddonCheckoutRequest, StartCloudCheckoutRequest, SuggestPublicUrlSlugRequest, }; use serde::{Deserialize, Serialize}; -use std::fs::OpenOptions; -use std::io::Write; -use std::path::PathBuf; #[cfg(target_os = "macos")] use std::process::Command; use std::sync::{Mutex, MutexGuard}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::async_runtime; use tauri::{AppHandle, Runtime}; -use tauri_plugin_updater::UpdaterExt; use localify::desktop::dto::{ServiceRunRequest, ServiceStopDto}; use localify::desktop::shell_environment::ShellEnvironmentDto; @@ -47,6 +42,8 @@ use localify::desktop::terminal::{ TerminalManager, TerminalResizeRequest, TerminalRestartServiceRequest, TerminalSessionDto, TerminalSessionRequest, TerminalWriteRequest, }; +use localify::desktop::updates::{hand_off_update, open_app_blocker}; +use localify::infrastructure::updates::UpdateStatus; pub struct AppState { pub api: DesktopApi, @@ -106,21 +103,6 @@ pub struct DeepLinkLaunchDto { pub profile: Option, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AppUpdateInfoDto { - pub version: String, - pub notes: Option, - pub pub_date: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AppUpdateCheckDto { - pub configured: bool, - pub update: Option, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AutostartStatusDto { @@ -133,21 +115,18 @@ pub struct UpdateAutostartRequest { pub enabled: bool, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSettingsRequest { + pub automatic: bool, +} + const INSTALL_ATTRIBUTION_PREFIX: &str = "localify:install-attribution:v1:"; -const UPDATE_DOWNLOAD_ATTEMPTS: usize = 3; -const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(20); -const UPDATE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300); fn map_error(error: E) -> String { error.to_string() } -fn map_updater_error(stage: &str, error: E) -> String { - let message = format!("{stage}: {error}"); - append_updater_log(&format!("error {message}")); - message -} - /// Trust that is still pending does not count against the install: the machine /// has no screen to confirm on, and everything else works. fn install_completed(result: &InstallResultDto) -> bool { @@ -196,37 +175,6 @@ fn autostart_uninstall_step(app: &AppHandle) -> StepDto { } } -fn desktop_state_dir() -> Result { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) - .ok_or_else(|| "Failed to resolve the user home directory.".to_string())?; - - Ok(home - .join("Library/Application Support") - .join(localify::branding::BRANDING.desktop_state_dir_name())) -} - -fn append_updater_log(message: &str) { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs().to_string()) - .unwrap_or_else(|_| "unknown".into()); - let Ok(directory) = desktop_state_dir() else { - return; - }; - - if std::fs::create_dir_all(&directory).is_err() { - return; - } - - let path = directory.join("updater.log"); - let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else { - return; - }; - let _ = writeln!(file, "{timestamp} {message}"); -} - #[cfg(target_os = "macos")] fn read_clipboard_text() -> Result, String> { let output = Command::new("pbpaste") @@ -250,148 +198,6 @@ fn read_clipboard_text() -> Result, String> { Ok(None) } -fn build_updater( - app: &AppHandle, -) -> Result, String> { - let Some(endpoint) = super::updater_endpoint() else { - append_updater_log("build skipped updater_endpoint=none"); - return Ok(None); - }; - - append_updater_log(&format!("build endpoint={endpoint}")); - let builder = app - .updater_builder() - .pubkey(super::updater_public_key()) - .timeout(UPDATE_CHECK_TIMEOUT) - .endpoints(vec![tauri::Url::parse(endpoint).map_err(map_error)?]) - .map_err(|error| map_updater_error("configure updater endpoint", error))?; - let updater = super::configure_updater_builder(builder) - .build() - .map_err(|error| map_updater_error("build updater", error))?; - - Ok(Some(updater)) -} - -pub(crate) async fn check_app_update_with_handle( - app: AppHandle, -) -> Result { - let current_version = app.package_info().version.to_string(); - append_updater_log(&format!("check start current_version={current_version}")); - let Some(updater) = build_updater(&app)? else { - return Ok(AppUpdateCheckDto { - configured: false, - update: None, - }); - }; - - let update = updater - .check() - .await - .map_err(|error| map_updater_error("check update manifest", error))? - .map(|update| AppUpdateInfoDto { - version: update.version.to_string(), - notes: update.body, - pub_date: update.date.map(|value| value.to_string()), - }); - match &update { - Some(update) => append_updater_log(&format!("check available version={}", update.version)), - None => append_updater_log("check no_update"), - } - - Ok(AppUpdateCheckDto { - configured: true, - update, - }) -} - -pub(crate) async fn install_app_update_with_handle( - app: AppHandle, -) -> Result<(), String> { - append_updater_log("install start"); - let Some(updater) = build_updater(&app)? else { - return Err("Desktop updater is not configured for this build.".into()); - }; - - let Some(mut update) = updater - .check() - .await - .map_err(|error| map_updater_error("install check update manifest", error))? - else { - append_updater_log("install no_update"); - return Ok(()); - }; - - append_updater_log(&format!( - "install candidate version={} url={}", - update.version, update.download_url - )); - update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT); - - let mut downloaded_update = None; - let mut last_download_error = None; - for attempt in 1..=UPDATE_DOWNLOAD_ATTEMPTS { - append_updater_log(&format!( - "download start attempt={attempt}/{UPDATE_DOWNLOAD_ATTEMPTS}" - )); - let mut downloaded_bytes = 0usize; - let mut expected_bytes = None; - let mut next_progress_log = 5 * 1024 * 1024; - let download_result = update - .download( - |chunk_size, content_length| { - downloaded_bytes += chunk_size; - expected_bytes = content_length; - if downloaded_bytes >= next_progress_log { - append_updater_log(&format!( - "download progress attempt={attempt} bytes={downloaded_bytes} expected={expected_bytes:?}" - )); - next_progress_log += 5 * 1024 * 1024; - } - }, - || { - append_updater_log(&format!("download finished attempt={attempt}")); - }, - ) - .await; - - match download_result { - Ok(bytes) => { - append_updater_log(&format!( - "download verified attempt={attempt} bytes={}", - bytes.len() - )); - downloaded_update = Some(bytes); - last_download_error = None; - break; - } - Err(error) => { - let message = format!( - "{error}; attempt={attempt}; downloaded_bytes={downloaded_bytes}; expected_bytes={expected_bytes:?}" - ); - append_updater_log(&format!("download error {message}")); - last_download_error = Some(message); - } - } - } - - let Some(downloaded_update) = downloaded_update else { - let message = last_download_error.unwrap_or_else(|| "unknown download error".into()); - return Err(map_updater_error("download update", message)); - }; - - update - .install(&downloaded_update) - .map_err(|error| map_updater_error("install downloaded update", error))?; - - append_updater_log("install finished restarting"); - app.restart() -} - -#[tauri::command] -pub async fn check_app_update(app: tauri::AppHandle) -> Result { - check_app_update_with_handle(app).await -} - #[tauri::command] pub fn get_autostart_status(app: tauri::AppHandle) -> Result { Ok(AutostartStatusDto { @@ -414,8 +220,52 @@ pub fn set_autostart_enabled( } #[tauri::command] -pub async fn install_app_update(app: tauri::AppHandle) -> Result<(), String> { - install_app_update_with_handle(app).await +pub async fn get_update_status(state: tauri::State<'_, AppState>) -> Result { + let api = state.api.clone(); + async_runtime::spawn_blocking(move || api.update_status().map_err(map_error)) + .await + .map_err(map_error)? +} + +#[tauri::command] +pub async fn set_automatic_updates( + state: tauri::State<'_, AppState>, + request: UpdateSettingsRequest, +) -> Result { + let api = state.api.clone(); + async_runtime::spawn_blocking(move || { + api.set_automatic_updates(request.automatic) + .map_err(map_error) + }) + .await + .map_err(map_error)? +} + +#[tauri::command] +pub async fn check_for_updates(state: tauri::State<'_, AppState>) -> Result { + let api = state.api.clone(); + async_runtime::spawn_blocking(move || api.check_for_updates().map_err(map_error)) + .await + .map_err(map_error)? +} + +/// `Restart to update`. When the install can't be handed off, the app +/// says why and stays open. +#[tauri::command] +pub fn restart_to_update(app: tauri::AppHandle) -> Result<(), String> { + // Checked again now: the installer would refuse this bundle after the + // app quit, closing its tabs for nothing. A dev build is refused here + // too, as the CLI next to it would open the installed app instead. + if let Some(reason) = open_app_blocker() { + return Err(reason); + } + let cli = super::sibling_binary_path().ok_or_else(|| { + "This copy of Localify has no CLI in its bundle, so it can't install updates. Update it by hand." + .to_string() + })?; + hand_off_update(&cli).map_err(|error| format!("{error:#}"))?; + super::quit_app(&app); + Ok(()) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9c9d3f1..e1533f41 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,9 +3,11 @@ mod remote; mod terminal; use std::collections::HashMap; +use std::fs::File; #[cfg(target_os = "macos")] use std::io::Write; use std::path::PathBuf; +use std::process::Child; #[cfg(target_os = "macos")] use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -22,6 +24,8 @@ use localify::desktop::run_pty::AppInstallation; use localify::desktop::terminal::TerminalManager; use localify::infrastructure::running_services::RunningServiceStore; use localify::infrastructure::shell_env; +use localify::infrastructure::updates::UpdatePaths; +use localify::infrastructure::updates::app_lock::{AppLock, take_app_lock}; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::TrayIconBuilder; @@ -48,8 +52,6 @@ const TRAY_PROJECT_LIMIT: usize = 10; #[cfg(target_os = "macos")] const MENU_CHECK_UPDATES: &str = "menu-check-updates"; const TRAY_TEMPLATE_ICON: &[u8] = include_bytes!("../icons/tray-template.png"); -const UPDATER_PUBLIC_KEY: &str = include_str!("../updater.pub"); -const UPDATER_ENDPOINT: Option<&str> = option_env!("LOCALIFY_UPDATER_ENDPOINT"); const DEEP_LINK_EVENT: &str = "localify://deep-link"; const LOCALIFY_SCHEME: &str = "localify"; @@ -153,27 +155,6 @@ fn sibling_binary_path() -> Option { candidate.exists().then_some(candidate) } -pub(crate) fn updater_endpoint() -> Option<&'static str> { - if cfg!(debug_assertions) { - return None; - } - - UPDATER_ENDPOINT - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -pub(crate) fn updater_public_key() -> &'static str { - UPDATER_PUBLIC_KEY.trim() -} - -pub(crate) fn configure_updater_builder( - builder: tauri_plugin_updater::UpdaterBuilder, -) -> tauri_plugin_updater::UpdaterBuilder { - // Updater archives must be read byte-for-byte; let the signed .tar.gz stay opaque. - builder.configure_client(|client| client.no_gzip().no_brotli().no_zstd().no_deflate()) -} - fn runtime_desktop_name() -> String { BRANDING.runtime_desktop_name() } @@ -735,15 +716,21 @@ fn run_tray_action(app: &AppHandle, id: &str) -> Result<(), Strin sync_tray_menu(app).map_err(|error| error.to_string()) } TRAY_QUIT => { - let tray_state = app.state::(); - tray_state.quitting.store(true, Ordering::Relaxed); - app.exit(0); + quit_app(app); Ok(()) } _ => Ok(()), } } +/// Quits for good: closing the window only hides it. +fn quit_app(app: &AppHandle) { + app.state::() + .quitting + .store(true, Ordering::Relaxed); + app.exit(0); +} + fn handle_tray_action_error(app: &AppHandle, id: &str, error: String) { eprintln!("tray action '{id}' failed: {error}"); @@ -952,27 +939,6 @@ fn show_message_dialog( .blocking_show(); } -#[cfg(target_os = "macos")] -fn confirm_update_install( - app: &AppHandle, - version: &str, - current_version: &str, -) -> bool { - let message = format!( - "Version {version} is available.\n\nCurrent version: {current_version}\n\nInstall the update now? Localify will restart automatically when it finishes." - ); - - app.dialog() - .message(message) - .title("Update Available") - .kind(MessageDialogKind::Info) - .buttons(MessageDialogButtons::OkCancelCustom( - "Install".into(), - "Not now".into(), - )) - .blocking_show() -} - #[cfg(target_os = "macos")] fn finish_update_check(app: &AppHandle) { app.state::() @@ -990,39 +956,42 @@ async fn run_manual_update_check(app: AppHandle) { } } +/// The menu's check, the same one as `Check now` in Settings. Installing +/// goes through `Restart to update` in the window, which lists what it +/// closes. #[cfg(target_os = "macos")] async fn run_manual_update_check_inner(app: AppHandle) -> Result<(), String> { - let current_version = app.package_info().version.to_string(); - let update_check = commands::check_app_update_with_handle(app.clone()).await?; - - if !update_check.configured { - show_message_dialog( - &app, - "Updates Unavailable", - "Desktop updates are not configured for this build.".into(), - MessageDialogKind::Info, - ); - finish_update_check(&app); - return Ok(()); - } - - let Some(update) = update_check.update else { - show_message_dialog( - &app, + let api = app.state::().api.clone(); + let status = async_runtime::spawn_blocking(move || api.check_for_updates()) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + let (title, message) = match (&status.available, &status.reason) { + (None, _) => ( "You're Up to Date", - format!("Localify {current_version} is already the latest version."), - MessageDialogKind::Info, - ); - finish_update_check(&app); - return Ok(()); + format!( + "{} {} is the latest version.", + BRANDING.app_name, status.installed + ), + ), + (Some(version), Some(reason)) if !status.can_install => ( + "Update Available", + format!("{} {version} is available. {reason}", BRANDING.app_name), + ), + (Some(version), _) => ( + "Update Available", + format!( + "{} {version} is available. Use Restart to update in the window to install it.", + BRANDING.app_name + ), + ), }; - - if !confirm_update_install(&app, &update.version, ¤t_version) { - finish_update_check(&app); - return Ok(()); + if status.available.is_some() { + let _ = open_main_window(&app); } - - commands::install_app_update_with_handle(app).await + show_message_dialog(&app, title, message, MessageDialogKind::Info); + finish_update_check(&app); + Ok(()) } #[cfg(target_os = "macos")] @@ -1094,9 +1063,127 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> { sync_app_menu(app) } +/// The app's first step (docs/desktop.md, "Automatic updates"): it takes +/// `app.lock`, which it holds for its lifetime, so the installer never +/// replaces the bundle under it. While an install holds the lock, the app +/// waits for it. Then, whatever it found, it checks whether an install +/// replaced its bundle. `None` when the lock can't be taken: the app still +/// starts, as the installer also looks for processes running from the +/// bundle. +fn hold_app_lock() -> Option { + let paths = UpdatePaths::for_user()?; + let mut message = None; + let lock = take_app_lock(&paths, || message = show_updating_message()); + if let Some(mut message) = message { + let _ = message.kill(); + let _ = message.wait(); + } + restart_if_replaced(); + lock.inspect_err(|error| eprintln!("the app lock is off: {error:#}")) + .ok() +} + +/// `app.lock`, held for the app's lifetime. +struct HeldAppLock { + _file: Option, +} + +/// The lock the copy the single-instance plugin kept holds. A copy that +/// found `app.lock` held by another app was handed over and quit, unless +/// no other copy runs: then whoever held it has let go (an installer that +/// just ended, or a copy that quit since), and this one takes it now. +fn primary_app_lock(at_start: Option) -> Option { + match at_start { + Some(AppLock::Held(file)) => Some(file), + Some(AppLock::HeldByAnotherApp) => match hold_app_lock() { + Some(AppLock::Held(file)) => Some(file), + Some(AppLock::HeldByAnotherApp) => { + eprintln!("the app lock is off: another copy of the app holds it"); + None + } + None => None, + }, + None => None, + } +} + +#[cfg(target_os = "macos")] +fn show_updating_message() -> Option { + let script = format!( + "display dialog \"Updating {0}…\" with title \"{0}\" buttons {{\"OK\"}} default button 1 giving up after 600", + BRANDING.app_name + ); + Command::new("/usr/bin/osascript") + .args(["-e", &script]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() +} + +#[cfg(not(target_os = "macos"))] +fn show_updating_message() -> Option { + None +} + +/// Set on the process `restart_if_replaced` starts, so it starts at most once: +/// a bundle whose `Info.plist` names a newer version than its binary would +/// otherwise start itself forever. +#[cfg(target_os = "macos")] +const RESTARTED_ENV: &str = "LOCALIFY_RESTARTED_FOR_UPDATE"; + +/// An install may have replaced the bundle this process started from: the +/// newer version installed now starts instead. Only a newer one, by semver +/// precedence, and only once. +#[cfg(target_os = "macos")] +fn restart_if_replaced() { + use std::os::unix::process::CommandExt; + + use localify::infrastructure::updates::{macos::bundle_version, rules::parse_version}; + + if std::env::var_os(RESTARTED_ENV).is_some() { + // SAFETY: this runs first thing in `run`, before the app starts any + // thread that reads the environment. + unsafe { std::env::remove_var(RESTARTED_ENV) }; + return; + } + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(bundle) = exe + .ancestors() + .nth(3) + .filter(|bundle| bundle.extension().is_some_and(|ext| ext == "app")) + else { + return; + }; + let (Ok(installed), Some(running)) = ( + bundle_version(bundle), + parse_version(env!("CARGO_PKG_VERSION")), + ) else { + return; + }; + if installed.cmp_precedence(&running) != std::cmp::Ordering::Greater { + return; + } + let error = Command::new(&exe) + .args(std::env::args_os().skip(1)) + .env(RESTARTED_ENV, "1") + .exec(); + eprintln!("could not start Localify {installed}: {error}"); +} + +#[cfg(not(target_os = "macos"))] +fn restart_if_replaced() {} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let app_lock = hold_app_lock(); let api = DesktopApi::new(default_config_path(), default_binary_path()); + if let Err(error) = api.start_new_install() { + eprintln!("could not write a new install's config: {error:#}"); + } let mut builder = tauri::Builder::default(); @@ -1140,6 +1227,10 @@ pub fn run() { builder .setup(move |app| { + // Only the copy the single-instance plugin keeps gets here. + app.manage(HeldAppLock { + _file: primary_app_lock(app_lock), + }); // In setup: the panel's events need the app's handle. let terminal = TerminalManager::new( executable.clone(), @@ -1151,11 +1242,6 @@ pub fn run() { terminal.warm_shell_environment(); start_app_bridge(app.handle(), bridge_api.clone(), terminal.clone()); app.manage(terminal); - app.handle().plugin( - tauri_plugin_updater::Builder::new() - .pubkey(updater_public_key()) - .build(), - )?; let app_handle = app.handle().clone(); app_handle.clone().deep_link().on_open_url(move |event| { let urls = event.urls(); @@ -1193,8 +1279,10 @@ pub fn run() { commands::get_bootstrap, commands::get_autostart_status, commands::set_autostart_enabled, - commands::check_app_update, - commands::install_app_update, + commands::get_update_status, + commands::set_automatic_updates, + commands::check_for_updates, + commands::restart_to_update, commands::read_install_attribution_clipboard, commands::get_config, commands::update_config, diff --git a/src/application/install.rs b/src/application/install.rs index e7e561c5..b3145822 100644 --- a/src/application/install.rs +++ b/src/application/install.rs @@ -48,8 +48,10 @@ impl<'a> Install<'a> { let lan_ip = self.network.lan_ip().unwrap_or(Ipv4Addr::LOCALHOST); let dns_port = self.dns_port; - self.create_directories(&mut steps)?; + // Before anything is created: whether this is a new install, which + // decides automatic updates, is read from what is already there. self.ensure_config_file(&mut steps)?; + self.create_directories(&mut steps)?; self.init_root_ca(&mut steps); steps.extend(self.system.apply_system_changes()?); self.report_ca_trust(&mut steps); diff --git a/src/config.rs b/src/config.rs index 1178447e..b6e4432f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -166,8 +166,8 @@ impl Default for AgentConfig { #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct UpdatesConfig { /// Install new versions without asking. A config without the key - /// predates the feature and keeps updating by hand: `localify install` - /// writes it on for a new install. + /// predates the feature and keeps updating by hand: the app's first + /// start, or `localify install`, writes it on for a new install. #[serde(default)] pub automatic: bool, } diff --git a/src/desktop/api.rs b/src/desktop/api.rs index ff7f2fe5..a53bede9 100644 --- a/src/desktop/api.rs +++ b/src/desktop/api.rs @@ -2999,6 +2999,10 @@ impl DesktopApi { &self.binary_path } + pub(super) fn config_path(&self) -> &Path { + &self.config_path + } + /// The last run of every service, with what the app shows about it: a /// service still running from a shell tab of a previous version is a /// legacy run, kept as it is. diff --git a/src/desktop/mod.rs b/src/desktop/mod.rs index b85e8745..1824c48d 100644 --- a/src/desktop/mod.rs +++ b/src/desktop/mod.rs @@ -14,6 +14,7 @@ mod remote; pub mod run_pty; pub mod shell_environment; pub mod terminal; +pub mod updates; pub use crate::infrastructure::editor::EditorPreference; pub use crate::infrastructure::local_dev::TerminalPreference; diff --git a/src/desktop/updates.rs b/src/desktop/updates.rs new file mode 100644 index 00000000..9b783828 --- /dev/null +++ b/src/desktop/updates.rs @@ -0,0 +1,296 @@ +//! Updates in the app (docs/desktop.md, "Automatic updates"). The app never +//! installs: it shows the status the daemon answers, changes +//! `[updates] automatic`, runs the one coalesced check when asked, and on +//! `Restart to update` hands off to `localify update install --relaunch`. + +use std::io; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, anyhow, bail}; + +use super::DesktopApi; +use crate::branding::BRANDING; +use crate::infrastructure::config::ConfigStore; +use crate::infrastructure::mgmt_client::MgmtSocketClient; +use crate::infrastructure::updates::{ + self, UpdatePaths, UpdatePhase, UpdateStatus, release, state, +}; + +impl DesktopApi { + /// On a new install, writes the config with automatic updates on, before + /// the welcome can save a setting into a config without them. An + /// install that predates them, or left anything on this machine, is + /// left as it is (docs/desktop.md, "Automatic updates"). + pub fn start_new_install(&self) -> Result { + Ok(ConfigStore::new(self.config_path().to_path_buf()).start_new_install()?) + } + + /// The daemon's status, which knows whether it is checking or + /// installing now. With the daemon down, the CLI reads the same status + /// from the files: only the bundle's `localify` can tell whether its + /// bundle may be replaced. + /// + /// Either describes the daemon's bundle, so it is narrowed to what the + /// open app can do ([`open_app_blocker`]). + pub fn update_status(&self) -> Result { + let status = match self.update_client()?.update_status() { + Ok(status) => status, + Err(_) => self.update_cli(&["update", "--status", "--json"])?, + }; + Ok(for_open_app(status, open_app_blocker())) + } + + /// `Install updates automatically`. The daemon reads the setting again + /// before any unattended install, so it needs no restart. + pub fn set_automatic_updates(&self, automatic: bool) -> Result { + let status = match self.update_client()?.update_settings(automatic) { + Ok(status) => status, + Err(_) => { + let on = if automatic { "on" } else { "off" }; + self.update_cli(&["update", "--auto", on, "--json"])? + } + }; + Ok(for_open_app(status, open_app_blocker())) + } + + /// `Check now`: the check every caller goes through, so it shares the + /// machine-wide cooldown and leaves its answer where the daemon reads + /// it. + pub fn check_for_updates(&self) -> Result { + let endpoint = release::updater_endpoint() + .ok_or_else(|| anyhow!("This build has no update feed: update it by hand."))?; + let paths = UpdatePaths::for_user() + .ok_or_else(|| anyhow!("HOME is not set, so updates can't be checked."))?; + state::check(&paths, updates::unix_now(), || { + release::fetch_manifest(&endpoint) + })?; + self.update_status() + } + + fn update_client(&self) -> Result { + let (_, paths) = self.load_config_and_paths()?; + Ok(MgmtSocketClient::new(&paths.socket_path)) + } + + fn update_cli(&self, args: &[&str]) -> Result { + let output = Command::new(self.binary_path()) + .arg("--config") + .arg(self.config_path()) + .args(args) + .stdin(Stdio::null()) + .output() + .with_context(|| format!("Could not run {}", self.binary_path().display()))?; + if !output.status.success() { + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + serde_json::from_slice(&output.stdout) + .context("The update status from the CLI could not be read") + } +} + +/// Why the open app can't install an update itself, if it can't. +/// `Restart to update` hands off to this bundle's own CLI, whose installer +/// replaces this bundle and no other. The daemon's status speaks for the +/// daemon's bundle, which may be another copy: one in /Applications while +/// this one runs from a disk image or Downloads. +pub fn open_app_blocker() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + updates::blocker_of(&exe.with_file_name(BRANDING.cli_command())) +} + +/// The status, for an app whose own bundle `blocker` keeps from being +/// replaced: no `Restart to update`, and an update that waits says why, as +/// the daemon's own blocker would. +fn for_open_app(mut status: UpdateStatus, blocker: Option) -> UpdateStatus { + let Some(blocker) = blocker else { + return status; + }; + if status.can_install { + status.can_install = false; + if status.state == UpdatePhase::Available { + status.reason = Some(blocker); + } + } + status +} + +/// `Restart to update`: starts `localify update install --relaunch` from the +/// bundle's own CLI, in its own session with no terminal, so it outlives the +/// app. It waits for `app.lock`, installs, then opens the app again. The +/// caller quits right after, which releases the lock. +pub fn hand_off_update(cli: &Path) -> Result<()> { + let mut command = Command::new(cli); + command + .args(["update", "install", "--relaunch"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // SAFETY: `setsid` is async-signal-safe and reads no memory of the + // parent, which is all `pre_exec` asks of what runs between fork and + // exec. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command + .spawn() + .with_context(|| format!("Could not start {}", cli.display()))?; + // Reaped if the app is still there when it ends. + std::thread::spawn(move || child.wait()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::time::{Duration, Instant}; + + use super::*; + use crate::infrastructure::config::{Config, ConfigStore}; + use crate::infrastructure::paths::LocalifyPaths; + + fn script(dir: &Path, body: &str) -> PathBuf { + let path = dir.join("localify"); + fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + /// An API whose daemon isn't running and whose CLI is `body`. + fn api_without_daemon(dir: &Path, body: &str) -> DesktopApi { + let config_path = dir.join("config.toml"); + let mut config = Config::default(); + config.paths = LocalifyPaths { + data_dir: dir.join("data"), + pid_file: dir.join("localify.pid"), + log_file: dir.join("localify.log"), + certs_dir: dir.join("certs"), + socket_path: dir.join("localify.sock"), + }; + ConfigStore::new(config_path.clone()).save(&config).unwrap(); + DesktopApi::new(config_path, script(dir, body)) + } + + const STATUS: &str = r#"{"installed":"1.6.0","available":"1.7.0","state":"available","reason":null,"automatic":true,"lastCheckAt":5,"canInstall":true}"#; + + #[test] + fn without_the_daemon_the_status_comes_from_the_cli() { + let dir = tempfile::tempdir().unwrap(); + let args = dir.path().join("args"); + let body = format!("echo \"$@\" > '{}'\necho '{STATUS}'", args.display()); + let api = api_without_daemon(dir.path(), &body); + + let status = api.update_status().unwrap(); + + assert_eq!(status.available.as_deref(), Some("1.7.0")); + assert_eq!(status.last_check_at, Some(5)); + let config = dir.path().join("config.toml"); + assert_eq!( + fs::read_to_string(args).unwrap().trim(), + format!("--config {} update --status --json", config.display()) + ); + } + + #[test] + fn an_app_whose_own_bundle_cant_be_replaced_offers_no_restart() { + let dir = tempfile::tempdir().unwrap(); + // The daemon's bundle may be replaced; this test binary, like a copy + // run from a disk image, is not one that may. + let api = api_without_daemon(dir.path(), &format!("echo '{STATUS}'")); + + let status = api.update_status().unwrap(); + + assert!(!status.can_install); + let reason = status.reason.unwrap(); + assert_eq!(Some(reason), open_app_blocker()); + } + + #[test] + fn the_open_apps_blocker_keeps_the_reason_of_a_status_that_waits_for_nothing() { + let status: UpdateStatus = serde_json::from_str( + r#"{"installed":"1.6.0","available":null,"state":"upToDate","reason":"kept","automatic":true,"lastCheckAt":5,"canInstall":true}"#, + ) + .unwrap(); + + let status = for_open_app(status, Some("moved".into())); + + assert!(!status.can_install); + assert_eq!(status.reason.as_deref(), Some("kept")); + } + + #[test] + fn an_installable_app_keeps_the_daemons_status() { + let status: UpdateStatus = serde_json::from_str(STATUS).unwrap(); + + let status = for_open_app(status, None); + + assert!(status.can_install); + assert_eq!(status.reason, None); + } + + #[test] + fn without_the_daemon_the_cli_changes_the_setting() { + let dir = tempfile::tempdir().unwrap(); + let args = dir.path().join("args"); + let body = format!("echo \"$@\" > '{}'\necho '{STATUS}'", args.display()); + let api = api_without_daemon(dir.path(), &body); + + api.set_automatic_updates(false).unwrap(); + + let args = fs::read_to_string(args).unwrap(); + assert!(args.ends_with("update --auto off --json\n"), "{args}"); + } + + #[test] + fn a_cli_that_fails_says_why() { + let dir = tempfile::tempdir().unwrap(); + let api = api_without_daemon(dir.path(), "echo 'HOME is not set' >&2\nexit 1"); + + let error = api.update_status().unwrap_err(); + + assert_eq!(error.to_string(), "HOME is not set"); + } + + #[test] + fn the_handoff_runs_the_install_in_its_own_session_without_input() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out"); + let body = format!( + "if read -r line; then input=some; else input=none; fi\n\ + echo \"$@|$$|$(ps -o pgid= -p $$ | tr -d ' ')|$input\" > '{0}.tmp'\n\ + mv '{0}.tmp' '{0}'", + out.display() + ); + let cli = script(dir.path(), &body); + + hand_off_update(&cli).unwrap(); + + let started = Instant::now(); + while !out.exists() && started.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(50)); + } + let line = fs::read_to_string(&out).unwrap(); + let parts: Vec<&str> = line.trim().split('|').collect(); + assert_eq!(parts[0], "update install --relaunch"); + // `setsid` makes it the leader of a new session and process group. + assert_eq!(parts[1], parts[2]); + assert_eq!(parts[3], "none"); + } + + #[test] + fn a_handoff_that_cannot_start_fails_without_quitting_anything() { + let dir = tempfile::tempdir().unwrap(); + + let error = hand_off_update(&dir.path().join("missing")).unwrap_err(); + + assert!(error.to_string().starts_with("Could not start"), "{error}"); + } +} diff --git a/src/infrastructure/app_bridge.rs b/src/infrastructure/app_bridge.rs index e2cbf04f..33da5933 100644 --- a/src/infrastructure/app_bridge.rs +++ b/src/infrastructure/app_bridge.rs @@ -439,7 +439,9 @@ mod tests { let waited = started.elapsed(); assert!(matches!(handoff, Handoff::Unanswered(_)), "{handoff:?}"); assert!(waited >= ACK_TIMEOUT, "gave up after {waited:?}"); - assert!(waited < ACK_TIMEOUT + Duration::from_secs(2), "{waited:?}"); + // Bounded, not exact: a loaded test run delays the thread that gives + // up, so only a wait far past the timeout means it never gave up. + assert!(waited < ACK_TIMEOUT + Duration::from_secs(10), "{waited:?}"); assert_eq!( probe(&socket), AppPresence::Absent, diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index e7ed71e7..ed63b09d 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -1029,12 +1029,55 @@ fn normalized_label(value: &str) -> Option { pub struct ConfigStore { path: PathBuf, + /// Where an earlier Localify leaves something behind. `None` is this + /// machine's places ([`previous_install_traces`]). + install_traces: Option>, } impl ConfigStore { /// Create a new ConfigStore pointing at the given config file path pub fn new(path: PathBuf) -> Self { - Self { path } + Self { + path, + install_traces: None, + } + } + + /// A store that looks for an earlier install at `traces` instead of + /// this machine's places. + #[cfg(test)] + pub fn with_install_traces(path: PathBuf, traces: Vec) -> Self { + Self { + path, + install_traces: Some(traces), + } + } + + /// Whether this is a new install: no config here, and nothing an + /// earlier Localify leaves behind. A place that can't be checked counts + /// as taken, so an install that predates automatic updates never gets + /// them without its owner's choice. + pub fn is_new_install(&self) -> bool { + let traces = self + .install_traces + .clone() + .unwrap_or_else(previous_install_traces); + std::iter::once(self.path.clone()) + .chain(traces) + .all(|path| matches!(path.try_exists(), Ok(false))) + } + + /// Writes a new install's config, with automatic updates on, when this + /// is a new install. The app does it before its welcome can save any + /// setting, which would otherwise write a config with them off. Returns + /// whether it wrote one. + #[allow(dead_code)] // Used by the desktop through the library crate. + pub fn start_new_install(&self) -> Result { + if !self.is_new_install() { + return Ok(false); + } + self.save(&new_install_config(true))?; + Ok(true) } fn ensure_config_dir(&self) -> Result<(), ConfigError> { @@ -1046,6 +1089,9 @@ impl ConfigStore { Ok(()) } + /// A config that doesn't exist yet loads as the defaults, with + /// automatic updates off: only [`Self::start_new_install`] and + /// `localify install` turn them on, for a new install. pub fn load(&self) -> Result { if !self.path.exists() { return Ok(Config::default()); @@ -1733,18 +1779,52 @@ fn map_config_error(e: ConfigError) -> ConfigLoadError { } } +/// The config `localify install` or the app writes when there is none: +/// automatic updates are on only for a new install. +fn new_install_config(new_install: bool) -> Config { + let mut config = Config::default(); + config.updates.automatic = new_install; + config +} + +/// What an earlier Localify leaves on this machine: its root CA and +/// certificates, the daemon's pid and log, the launchd service, the DNS +/// resolver (or systemd-resolved's drop-in), the old `/etc` and +/// `/var/log` folders, and on macOS the old root helper's plists, copies +/// and run files. Not the data dir or the app's folder: the app makes them +/// on its first start. +pub fn previous_install_traces() -> Vec { + let paths = LocalifyPaths::default(); + let slug = BRANDING.slug(); + let mut traces = vec![ + paths.data_dir.join("ca.crt"), + paths.certs_dir, + paths.pid_file, + paths.log_file, + BRANDING.resolver_file(), + BRANDING.resolved_drop_in_file(), + PathBuf::from("/etc").join(slug), + PathBuf::from("/var/log").join(slug), + ]; + if cfg!(target_os = "macos") { + use crate::infrastructure::macos_system::{ + LegacyRoots, legacy_install_traces, service_plist_path, + }; + traces.push(service_plist_path()); + traces.extend(legacy_install_traces(&LegacyRoots::system())); + } + traces +} + impl ConfigLoader for ConfigStore { fn load(&self) -> Result<(DaemonConfig, LocalifyPaths), ConfigLoadError> { let config = ConfigStore::load(self).map_err(map_config_error)?; Ok((config.daemon, config.paths)) } - /// A new install's config: automatic updates start on, unlike a config - /// that predates them. fn save_defaults(&self) -> Result<(), ConfigLoadError> { - let mut config = Config::default(); - config.updates.automatic = true; - self.save(&config).map_err(map_config_error) + self.save(&new_install_config(self.is_new_install())) + .map_err(map_config_error) } fn exists(&self) -> bool { @@ -1814,15 +1894,59 @@ mod tests { // --- [updates] automatic --- + /// A store on a machine where nothing of an earlier install is left. + fn new_machine() -> (tempfile::TempDir, ConfigStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::with_install_traces( + dir.path().join("config.toml"), + vec![dir.path().join("etc-localify"), dir.path().join("ca.crt")], + ); + (dir, store) + } + #[test] fn a_new_install_writes_automatic_updates_on() { - let (_dir, store) = temp_store(); + let (_dir, store) = new_machine(); ConfigLoader::save_defaults(&store).unwrap(); assert!(store.load().unwrap().updates.automatic); let text = fs::read_to_string(store.path.clone()).unwrap(); assert!(text.contains("[updates]\nautomatic = true"), "{text}"); } + #[test] + fn an_install_over_an_earlier_ones_traces_keeps_automatic_updates_off() { + let (dir, store) = new_machine(); + fs::create_dir(dir.path().join("etc-localify")).unwrap(); + ConfigLoader::save_defaults(&store).unwrap(); + assert!(!store.load().unwrap().updates.automatic); + } + + #[test] + fn a_setting_saved_after_a_new_install_starts_keeps_automatic_updates_on() { + let (_dir, store) = new_machine(); + assert!(store.start_new_install().unwrap()); + let mut config = store.load().unwrap(); + config.agent.connect_agents = false; + store.save(&config).unwrap(); + assert!(store.load().unwrap().updates.automatic); + // Once there is a config, it is no longer a new install. + assert!(!store.start_new_install().unwrap()); + } + + #[test] + fn a_machine_with_an_earlier_install_does_not_start_as_a_new_one() { + let (dir, store) = new_machine(); + fs::write(dir.path().join("ca.crt"), "").unwrap(); + assert!(!store.start_new_install().unwrap()); + assert!(!store.path.exists()); + } + + #[test] + fn a_missing_config_reads_automatic_updates_off() { + let (_dir, store) = temp_store(); + assert!(!store.load().unwrap().updates.automatic); + } + #[test] fn a_config_from_before_automatic_updates_keeps_them_off() { let (_dir, store) = temp_store(); diff --git a/src/infrastructure/macos_system.rs b/src/infrastructure/macos_system.rs index 5cef7171..8bf7dbf0 100644 --- a/src/infrastructure/macos_system.rs +++ b/src/infrastructure/macos_system.rs @@ -326,6 +326,26 @@ impl LegacyInstall { } } +/// What an install from before the user daemon leaves, for telling a new +/// install from an old one: its helpers, their copies, and its run files. +/// A folder that can't be read for any reason but being absent counts as +/// one, so a doubt never reads as a new install. +pub fn legacy_install_traces(roots: &LegacyRoots) -> Vec { + let legacy = LegacyInstall::detect(roots); + let mut traces = Vec::new(); + traces.extend(legacy.helper_plists); + traces.extend(legacy.helper_copies); + traces.extend(legacy.run_files); + for dir in [&roots.launch_daemons, &roots.helper_tools, &roots.var_run] { + if let Err(error) = fs::read_dir(dir) + && error.kind() != std::io::ErrorKind::NotFound + { + traces.push(dir.clone()); + } + } + traces +} + fn entries_starting_with(dir: &Path, prefix: &str) -> Vec { let Ok(entries) = fs::read_dir(dir) else { return Vec::new(); @@ -377,19 +397,24 @@ fn copy_config_to_user_folder(legacy_path: &Path, config_path: &Path) -> Result< ) })?; // The script removes the old folder, so its domains must not be lost when a newer - // config already exists. That config keeps its own settings. + // config already exists. That config keeps its own settings, except automatic + // updates: the app's welcome can write that config before this runs, and an + // install that predates them keeps them off until its owner turns them on. if config_path.exists() { let store = ConfigStore::new(config_path.to_path_buf()); let mut current = store .load() .context("Failed to read the config in your user folder")?; + let automatic = migrated.updates.automatic; + let changed = current.updates.automatic != automatic; + current.updates.automatic = automatic; let added = current.adopt_missing_from(migrated); - if added > 0 { + if added > 0 || changed { store .save(¤t) .context("Failed to save the config in your user folder")?; } - return Ok(added > 0); + return Ok(added > 0 || changed); } migrated.paths = LocalifyPaths::default(); @@ -1313,6 +1338,20 @@ mod tests { } } + #[test] + fn an_old_helper_left_alone_is_a_trace_of_an_earlier_install() { + let dir = tempfile::tempdir().unwrap(); + let roots = empty_roots(dir.path()); + assert!(legacy_install_traces(&roots).is_empty()); + + let helper = roots + .launch_daemons + .join(format!("{}.dns.plist", BRANDING.legacy_helper_prefix())); + fs::write(&helper, "").unwrap(); + + assert_eq!(legacy_install_traces(&roots), vec![helper]); + } + fn empty_roots(dir: &Path) -> LegacyRoots { let root = |name: &str| { let path = dir.join(name); @@ -1892,6 +1931,36 @@ SHA-1 hash: AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:00:11:22:33 assert_eq!(config.daemon.http_port, 9000); } + #[test] + fn migrating_after_the_welcome_saved_a_setting_keeps_automatic_updates_off() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("legacy.toml"); + fs::write(&legacy, "[daemon]\nhttp_port = 8081\n").unwrap(); + let target = dir.path().join("config.toml"); + // The welcome's "Connect agents" saves a config before activation. + let store = ConfigStore::new(target.clone()); + let mut config = store.load().unwrap(); + config.agent.connect_agents = false; + store.save(&config).unwrap(); + + copy_config_to_user_folder(&legacy, &target).unwrap(); + + assert!(!store.load().unwrap().updates.automatic); + } + + #[test] + fn migrating_takes_automatic_updates_from_the_old_config() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("legacy.toml"); + fs::write(&legacy, "[daemon]\nhttp_port = 8081\n").unwrap(); + let target = dir.path().join("config.toml"); + fs::write(&target, "[updates]\nautomatic = true\n").unwrap(); + + assert!(copy_config_to_user_folder(&legacy, &target).unwrap()); + + assert!(!ConfigStore::new(target).load().unwrap().updates.automatic); + } + #[test] fn migrating_never_overwrites_a_config_in_the_users_folder() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/infrastructure/updates/app_lock.rs b/src/infrastructure/updates/app_lock.rs new file mode 100644 index 00000000..e6d94ead --- /dev/null +++ b/src/infrastructure/updates/app_lock.rs @@ -0,0 +1,198 @@ +//! The app's lifetime lock (docs/desktop.md, "Automatic updates"). The app +//! takes `app.lock` as its first step and holds it until it exits, so the +//! installer never replaces the bundle under it. + +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; + +use super::UpdatePaths; + +/// How often a waiting app tries again. +const POLL: Duration = Duration::from_millis(200); +/// A swap takes a second or two: the app says it is updating only past this. +pub const MESSAGE_AFTER: Duration = Duration::from_secs(1); +/// An installer lets go of `update.lock` just before `app.lock`, and the +/// daemon's check takes `app.lock` alone for an instant: `app.lock` held +/// with `update.lock` free is tried again this many times before it counts +/// as another app. +const RELEASE_TRIES: u32 = 5; +const RELEASE_POLL: Duration = Duration::from_millis(20); + +/// What the app's start found. +#[derive(Debug)] +pub enum AppLock { + /// The app holds the lock for as long as it keeps `file` open. + Held(File), + /// Another copy of the app holds it. This copy hands its launch over to + /// that one, so it never waits for it. + HeldByAnotherApp, +} + +/// Takes `app.lock`. An installer holds it only while it also holds +/// `update.lock`, so a lock held without `update.lock` is another copy of +/// the app, once it stays held a little. While an install holds it, this +/// waits, and calls `on_wait` once the wait passes [`MESSAGE_AFTER`]. +/// +/// Looking at `update.lock` takes it for an instant when it is free. An +/// unattended install that tries it at that instant gives up until its next +/// check, which it would do anyway, since the app holds `app.lock`. +pub fn take_app_lock(paths: &UpdatePaths, on_wait: impl FnOnce()) -> Result { + take(paths, on_wait, || {}) +} + +/// [`take_app_lock`], with `between_probes` called after `app.lock` was +/// found held and before `update.lock` is looked at: tests end an install +/// there. +fn take( + paths: &UpdatePaths, + on_wait: impl FnOnce(), + mut between_probes: impl FnMut(), +) -> Result { + let app_lock = paths.app_lock(); + let file = open_lock(&app_lock)?; + let started = Instant::now(); + let mut on_wait = Some(on_wait); + loop { + if try_lock(&file, &app_lock)? { + return Ok(AppLock::Held(file)); + } + between_probes(); + if !is_held(&paths.update_lock())? { + // No install now: one may have just ended, letting go of both. + for _ in 0..RELEASE_TRIES { + if try_lock(&file, &app_lock)? { + return Ok(AppLock::Held(file)); + } + std::thread::sleep(RELEASE_POLL); + } + if !is_held(&paths.update_lock())? { + return Ok(AppLock::HeldByAnotherApp); + } + } + if started.elapsed() >= MESSAGE_AFTER + && let Some(on_wait) = on_wait.take() + { + on_wait(); + } + std::thread::sleep(POLL); + } +} + +fn try_lock(file: &File, path: &Path) -> Result { + match file.try_lock() { + Ok(()) => Ok(true), + Err(TryLockError::WouldBlock) => Ok(false), + Err(TryLockError::Error(error)) => { + Err(error).with_context(|| format!("Could not lock {}", path.display())) + } + } +} + +fn is_held(path: &Path) -> Result { + Ok(!try_lock(&open_lock(path)?, path)?) +} + +fn open_lock(path: &Path) -> Result { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("Could not create {}", dir.display()))?; + } + OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path) + .with_context(|| format!("Could not open {}", path.display())) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc; + + use super::*; + + fn paths() -> (tempfile::TempDir, UpdatePaths) { + let dir = tempfile::tempdir().unwrap(); + let paths = UpdatePaths::new(dir.path().join("Localify")); + (dir, paths) + } + + fn held(path: &Path) -> File { + let file = open_lock(path).unwrap(); + file.lock().unwrap(); + file + } + + #[test] + fn a_free_lock_is_taken_and_kept_until_the_file_closes() { + let (_dir, paths) = paths(); + let AppLock::Held(file) = take_app_lock(&paths, || {}).unwrap() else { + panic!("the lock was free"); + }; + assert!(is_held(&paths.app_lock()).unwrap()); + drop(file); + assert!(!is_held(&paths.app_lock()).unwrap()); + } + + #[test] + fn another_app_holding_it_is_not_waited_for() { + let (_dir, paths) = paths(); + let _other_app = held(&paths.app_lock()); + let called = AtomicBool::new(false); + let lock = take_app_lock(&paths, || called.store(true, Ordering::SeqCst)).unwrap(); + assert!(matches!(lock, AppLock::HeldByAnotherApp)); + assert!(!called.load(Ordering::SeqCst)); + } + + #[test] + fn an_install_holding_both_locks_is_waited_for_with_a_message() { + let (_dir, paths) = paths(); + let update = held(&paths.update_lock()); + let app = held(&paths.app_lock()); + let (said, heard) = mpsc::channel(); + let installer = std::thread::spawn(move || { + // The message comes once the wait is long enough, then the + // install ends. + heard.recv_timeout(Duration::from_secs(10)).unwrap(); + drop(app); + drop(update); + }); + let lock = take_app_lock(&paths, move || said.send(()).unwrap()).unwrap(); + installer.join().unwrap(); + assert!(matches!(lock, AppLock::Held(_)), "the install ended"); + assert!(is_held(&paths.app_lock()).unwrap()); + } + + #[test] + fn a_short_install_ends_before_any_message() { + let (_dir, paths) = paths(); + let update = held(&paths.update_lock()); + let app = held(&paths.app_lock()); + let installer = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(app); + drop(update); + }); + let called = AtomicBool::new(false); + let lock = take_app_lock(&paths, || called.store(true, Ordering::SeqCst)).unwrap(); + installer.join().unwrap(); + assert!(matches!(lock, AppLock::Held(_))); + assert!(!called.load(Ordering::SeqCst)); + } + + #[test] + fn an_install_that_ends_between_the_two_probes_is_not_another_app() { + let (_dir, paths) = paths(); + let mut installer = Some((held(&paths.update_lock()), held(&paths.app_lock()))); + // The installer lets go of both locks right after `app.lock` was + // found held, and before `update.lock` is looked at. + let lock = take(&paths, || {}, || drop(installer.take())).unwrap(); + assert!(matches!(lock, AppLock::Held(_))); + assert!(is_held(&paths.app_lock()).unwrap()); + } +} diff --git a/src/infrastructure/updates/macos.rs b/src/infrastructure/updates/macos.rs index bd0db0b0..0bfe1960 100644 --- a/src/infrastructure/updates/macos.rs +++ b/src/infrastructure/updates/macos.rs @@ -65,12 +65,21 @@ impl InstallTarget { /// The bundle holding this very binary, when it is the production app /// at a supported place, owned and replaceable by this account. pub fn current() -> Result { + let exe = std::env::current_exe() + .map_err(|error| TargetError::Unreadable("this binary".into(), error.to_string()))?; + Self::of(&exe) + } + + /// The bundle holding the `localify` at `cli`, on the same terms as + /// [`Self::current`]: the open app asks it of its own bundle's CLI, + /// which is what installs when it hands off. + pub fn of(cli: &Path) -> Result { if BRANDING.is_dev_profile() || cfg!(debug_assertions) { return Err(TargetError::DevBuild); } - let exe = std::env::current_exe() - .and_then(std::fs::canonicalize) - .map_err(|error| TargetError::Unreadable("this binary".into(), error.to_string()))?; + let exe = std::fs::canonicalize(cli).map_err(|error| { + TargetError::Unreadable(cli.display().to_string(), error.to_string()) + })?; let home = user_home_dir().unwrap_or_default(); let allowed = [ PathBuf::from("/Applications/Localify.app"), diff --git a/src/infrastructure/updates/mod.rs b/src/infrastructure/updates/mod.rs index 6ea08c1f..08ef0aa9 100644 --- a/src/infrastructure/updates/mod.rs +++ b/src/infrastructure/updates/mod.rs @@ -6,6 +6,8 @@ // the macOS installer, but build everywhere so their tests run on Linux too. #![cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[allow(dead_code)] // Used by the desktop through the library crate. +pub mod app_lock; pub mod extract; #[cfg(target_os = "macos")] pub mod install; @@ -101,13 +103,6 @@ impl UpdatePaths { } } -/// The lock the app takes as its first step and holds for its lifetime, -/// so the installer never replaces it while it runs. -#[allow(dead_code)] // Used by the desktop through the library crate. -pub fn app_lock_path() -> Option { - UpdatePaths::for_user().map(|paths| paths.app_lock()) -} - /// Appends a line to `updater.log`. Logging never fails an update. pub fn log(paths: &UpdatePaths, message: &str) { let _ = std::fs::create_dir_all(paths.dir()); @@ -137,7 +132,8 @@ pub(crate) fn random_u64() -> u64 { hasher.finish() } -/// `[updates] automatic`. A config that can't be read counts as off. +/// `[updates] automatic`. A config that is missing or can't be read counts +/// as off. pub fn automatic(config_path: &Path) -> bool { ConfigStore::new(config_path.to_path_buf()) .load() @@ -172,6 +168,13 @@ pub struct UpdateStatus { /// check failed. pub reason: Option, pub automatic: bool, + /// When the last check ran, in Unix seconds. + #[serde(rename = "lastCheckAt", default)] + pub last_check_at: Option, + /// Whether this copy may be replaced at all. When it may not, `reason` + /// says why, and only a manual install updates it. + #[serde(rename = "canInstall", default)] + pub can_install: bool, } /// `state` in [`UpdateStatus`]. @@ -210,6 +213,7 @@ pub fn describe( .as_ref() .and_then(|release| parse_version(&release.version)) .filter(|offered| rules::offer_is_newer(offered, installed, state.highest().as_ref())); + let can_install = blocker.is_none(); let stranded = || { state .stranded_previous @@ -237,6 +241,8 @@ pub fn describe( state: phase, reason, automatic, + last_check_at: state.last_check_at, + can_install, } } @@ -253,6 +259,8 @@ pub fn describe_unreadable( state: UpdatePhase::Failed, reason: Some(format!("{error:#}")), automatic, + last_check_at: None, + can_install: false, } } @@ -434,6 +442,21 @@ fn blocker() -> Option { Some(LINUX_REASON.to_string()) } +/// Why the bundle whose `localify` is `cli` can't be replaced, if it +/// can't: [`blocker`]'s rules, for another copy than this binary's. +#[allow(dead_code)] // Used by the desktop through the library crate. +pub fn blocker_of(cli: &Path) -> Option { + #[cfg(target_os = "macos")] + return macos::InstallTarget::of(cli) + .err() + .map(|error| error.to_string()); + #[cfg(not(target_os = "macos"))] + { + let _ = cli; + Some(LINUX_REASON.to_string()) + } +} + #[cfg(test)] mod tests { use super::release::Release; @@ -454,6 +477,15 @@ mod tests { parse_version(value).unwrap() } + #[test] + fn a_missing_or_unreadable_config_reads_automatic_updates_off() { + let dir = tempfile::tempdir().unwrap(); + assert!(!automatic(&dir.path().join("config.toml"))); + let broken = dir.path().join("broken.toml"); + std::fs::write(&broken, "[updates\nautomatic = true\n").unwrap(); + assert!(!automatic(&broken)); + } + #[test] fn a_newer_release_is_available_and_says_why_it_waits() { let status = describe(&v("1.6.0"), &checked("1.7.0"), false, None, Activity::Idle); @@ -472,6 +504,8 @@ mod tests { Activity::Idle, ); assert_eq!(linux.reason.as_deref(), Some(LINUX_REASON)); + assert!(on.can_install); + assert!(!linux.can_install); } #[test] @@ -510,6 +544,7 @@ mod tests { let status = updater.status(); assert_eq!(status.state, UpdatePhase::Failed); assert_eq!(status.available, None); + assert!(!status.can_install); let reason = status.reason.unwrap(); assert!( reason.contains("updates.json") && reason.contains("delete it"), @@ -544,6 +579,8 @@ mod tests { "state": "available", "reason": null, "automatic": true, + "lastCheckAt": 1, + "canInstall": true, }) ); }