Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
894 changes: 894 additions & 0 deletions docs/superpowers/plans/2026-06-13-update-check.md

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions docs/superpowers/specs/2026-06-13-update-check-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# GitHub update check — design

## Summary

Add a lightweight update checker that compares the running app version against
the latest GitHub release. It surfaces a **"Check for updates"** button in
Settings (with a Download link when a newer version exists) and a **once-per-day
automatic check on startup** that shows an informational toast when an update is
available. No auto-download/install — just notify and link.

## Decisions made

- **Version check only, not a native auto-updater.** Electron's built-in
`autoUpdater` (Squirrel) supports only macOS + Windows, requires macOS code
signing, and has no Linux support; it also fits poorly with the Electron Forge
build (no hosted feed). A GitHub Releases version check works identically on
all platforms, needs no signing, and adds no npm dependency (Node's global
`fetch` is available in the main process).
- **Trigger:** manual button in Settings **plus** an automatic startup check
throttled to **at most once per 24 hours**.
- **No new dependency.** Uses `globalThis.fetch`, the existing `app.getVersion()`
injection, and the existing `openExternal` IPC.
- **Toasts are text-only** (current `ToastProvider`), so the startup toast is
purely informational; the actionable Download link lives in Settings.

## Data source

`GET https://api.github.com/repos/NoiXdev/s3Manager/releases/latest` with headers
`Accept: application/vnd.github+json` and a `User-Agent` (GitHub requires one).
`/releases/latest` excludes drafts and pre-releases. Response of interest:
`tag_name` (e.g. `v1.2.0`) and `html_url` (the release page). A **404** means no
published release yet → treat as "up to date". Unauthenticated rate limit is
60 req/h — ample for occasional checks.

## Components

### Main — `src/main/update/checkForUpdate.ts`

- `const GITHUB_REPO = 'NoiXdev/s3Manager'`.
- `compareVersions(a, b): number` — strips a leading `v` and any `-prerelease`
suffix, compares `major.minor.patch` numerically (so `1.10.0 > 1.9.0`).
Returns `>0` if `a` is newer, `0` if equal, `<0` if older.
- `interface UpdateInfo { currentVersion: string; latestVersion: string | null; updateAvailable: boolean; releaseUrl: string }`.
- `async function checkForUpdate({ fetchImpl, currentVersion }): Promise<Result<UpdateInfo>>`:
- Fetches the latest-release endpoint via `fetchImpl`.
- On HTTP 404 → `ok({ currentVersion, latestVersion: null, updateAvailable: false, releaseUrl: 'https://github.com/NoiXdev/s3Manager/releases' })`.
- On non-OK (e.g. 403 rate-limit, 5xx) → `err(...)` with a readable message.
- On OK → parse `tag_name`/`html_url`; `updateAvailable = compareVersions(tag, currentVersion) > 0`; `releaseUrl = html_url ?? <releases page>`.
- On thrown fetch/parse error → `err(message)`.

### Main — IPC wiring

- New channel `checkForUpdate: 'app:checkForUpdate'`, `{ args: []; res: Result<UpdateInfo> }`. `UpdateInfo` is imported from the update module into `channels.ts`.
- `RegisterDeps` gains `fetchImpl?: typeof fetch` (optional; defaults to `globalThis.fetch`). `main.ts` injects `fetchImpl: (...a) => globalThis.fetch(...a)` (or omits it to use the default).
- Handler: `h(CH.checkForUpdate, () => checkForUpdate({ fetchImpl: deps.fetchImpl ?? globalThis.fetch, currentVersion: deps.appVersion }))`. The handler is **pure** — it does not persist anything (the daily-throttle timestamp is owned by the renderer; see below).
- `preload.ts`: `checkForUpdate: () => invoke(CH.checkForUpdate)`.

### Settings persistence — `src/main/settings/appSettings.ts`

- `AppSettings` gains `autoCheckUpdates: boolean` (default **true**) and
`lastUpdateCheckAt: number | null` (default **null**).
- `readSettings`: parse `autoCheckUpdates` from `'true'`/`'false'` (default true);
parse `lastUpdateCheckAt` as a finite number ≥ 0 or null.
- `writeSettings`: handle `autoCheckUpdates` (store `String(boolean)`) and
`lastUpdateCheckAt` (store `String(number)` when a finite number ≥ 0).

### Renderer — throttle helper `src/renderer/lib/updateThrottle.ts`

- `const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000`.
- `shouldAutoCheck({ autoCheckUpdates, lastUpdateCheckAt, now, intervalMs = UPDATE_CHECK_INTERVAL_MS }): boolean` →
`autoCheckUpdates && (lastUpdateCheckAt == null || now - lastUpdateCheckAt >= intervalMs)`.

### Renderer — hook `src/renderer/hooks/useUpdateCheck.ts`

- A TanStack `useMutation` whose `mutationFn` calls `unwrap(window.s3.checkForUpdate())`.
- `onSuccess` records the check time for the daily throttle by calling
`window.s3.setSettings({ lastUpdateCheckAt: Date.now() })` (fire-and-forget;
no settings-query invalidation needed — the value is read fresh on next launch).
- Returns the mutation (`mutate`, `data`, `isPending`, `isError`, `error`).

### Renderer — Settings UI (`SettingsScreen.tsx`, "About" area)

- A **"Check for updates"** button → `check.mutate()`. Inline status from the
mutation state:
- pending → `settings.checkingUpdates`
- success + `!updateAvailable` → `settings.upToDate`
- success + `updateAvailable` → `settings.updateAvailable` (with version) and a
**Download** link/button → `window.s3.openExternal(data.releaseUrl)`
- error → `settings.updateCheckFailed`
- A checkbox toggle **"Check for updates on startup"** bound to
`autoCheckUpdates` (saved via the existing `save` mutation).

### Renderer — startup auto-check (`App.tsx`)

- Instantiate `const check = useUpdateCheck()`.
- One-shot `useEffect` guarded by a `useRef(false)`: when `settings.isSuccess`
and `shouldAutoCheck({ autoCheckUpdates, lastUpdateCheckAt, now: Date.now() })`,
call `check.mutate()` once.
- A second `useEffect`: when `check.data?.updateAvailable` is true, `show(t('updates.available', { version: check.data.latestVersion }))` once. Auto-check errors are ignored (no toast — don't nag offline users).

## i18n (all six locales)

`settings.checkUpdates`, `settings.checkingUpdates`, `settings.upToDate`,
`settings.updateAvailable` ("Version {{version}} available"),
`settings.updateDownload`, `settings.updateCheckFailed`,
`settings.autoCheck` (toggle label), `settings.autoCheckHelp`,
`updates.available` (toast, "Update available: {{version}}").

## Error handling & edge cases

- **Offline / network error:** manual → inline `updateCheckFailed`; auto → silent.
- **No releases yet (404):** treated as up to date.
- **Rate limited (403):** `err` → manual shows failure; auto silent.
- **Pre-releases:** excluded by `/releases/latest`.
- **Dev build:** `app.getVersion()` returns the `package.json` version; comparison still works.

## Testing (TDD)

- `checkForUpdate.test.ts`: stubbed `fetchImpl` for update-available, up-to-date,
404-no-release, non-OK (403/500), and thrown-error cases; `compareVersions`
unit cases incl. `1.10.0 > 1.9.0`, equal, `v`-prefix, pre-release suffix.
- `appSettings.test.ts`: `autoCheckUpdates` default true and persists false;
`lastUpdateCheckAt` default null and persists a number; ignores invalid values.
- `register.test.ts`: a `checkForUpdate` handler test with a stubbed `fetchImpl`
returning a newer tag → `updateAvailable: true`.
- `updateThrottle.test.ts`: `shouldAutoCheck` true when never checked, true when
≥24h, false when <24h, false when `autoCheckUpdates` is false.
- `useUpdateCheck.test.tsx`: mocks `window.s3.checkForUpdate` + `setSettings`;
asserts data flows through and `setSettings` is called with `lastUpdateCheckAt`.
- `SettingsScreen.test.tsx`: button → up-to-date and update-available states;
Download calls `openExternal`; toggle persists `autoCheckUpdates`.
- `App.test.tsx`: with `checkForUpdate` returning `updateAvailable` and a
due/never `lastUpdateCheckAt`, a toast appears; with a recent `lastUpdateCheckAt`,
no auto-check fires (`checkForUpdate` not called).

## Out of scope

- Auto-download/install, delta updates, release-notes rendering in-app.
- Per-channel (beta) updates.
- Reminders/snooze beyond the 24h startup throttle.

## Open questions

None.
3 changes: 3 additions & 0 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ObjectRetention, LegalHoldStatus } from '../s3/objectRetention';
import type { Endpoint, SyncPlan, SyncResult } from '../s3/sync';
import type { LocalSyncArgs } from '../s3/localSync';
import type { AppSettings, AppInfo } from '../settings/appSettings';
import type { UpdateInfo } from '../update/checkForUpdate';
import type { ObjectAcl } from '../s3/objectAcl';
import type { EditableMetadata } from '../s3/objectMetadata';

Expand Down Expand Up @@ -53,6 +54,7 @@ export const CH = {
setSettings: 'settings:set',
getAppInfo: 'app:getInfo',
openExternal: 'shell:openExternal',
checkForUpdate: 'app:checkForUpdate',
getObjectAcl: 's3:getObjectAcl',
putObjectAcl: 's3:putObjectAcl',
getEditableMetadata: 's3:getEditableMetadata',
Expand Down Expand Up @@ -137,6 +139,7 @@ export interface ApiMap {
[CH.setSettings]: { args: [Partial<AppSettings>]; res: Result<AppSettings> };
[CH.getAppInfo]: { args: []; res: Result<AppInfo> };
[CH.openExternal]: { args: [string]; res: Result<true> };
[CH.checkForUpdate]: { args: []; res: Result<UpdateInfo> };
[CH.getObjectAcl]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result<ObjectAcl> };
[CH.putObjectAcl]: { args: [{ accountId: string; bucket: string; key: string; acl: ObjectAcl }]; res: Result<true> };
[CH.getEditableMetadata]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result<EditableMetadata> };
Expand Down
21 changes: 19 additions & 2 deletions src/main/ipc/register.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { describe, it, expect, beforeEach } from 'vitest';

Check warning on line 1 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'/home/runner/work/s3Manager/s3Manager/node_modules/vitest/dist/index.js' imported multiple times
import { mockClient } from 'aws-sdk-client-mock';
import { S3Client, ListBucketsCommand, GetObjectCommand, GetBucketCorsCommand, GetObjectLockConfigurationCommand, ListObjectsV2Command, PutObjectAclCommand, GetObjectRetentionCommand, PutObjectLegalHoldCommand, GetObjectAclCommand, HeadObjectCommand, CopyObjectCommand, CreateBucketCommand } from '@aws-sdk/client-s3';

Check warning on line 3 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'/home/runner/work/s3Manager/s3Manager/node_modules/@aws-sdk/client-s3/dist-es/index.js' imported multiple times
import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs';
import { Readable } from 'node:stream';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { PutObjectCommand } from '@aws-sdk/client-s3';

Check warning on line 8 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'/home/runner/work/s3Manager/s3Manager/node_modules/@aws-sdk/client-s3/dist-es/index.js' imported multiple times
import { vi } from 'vitest';

Check warning on line 9 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'/home/runner/work/s3Manager/s3Manager/node_modules/vitest/dist/index.js' imported multiple times
import { registerIpc, type IpcMainLike } from './register';
import { CH, UPLOAD_PROGRESS_CHANNEL, SYNC_PROGRESS_CHANNEL } from './channels';
import { openDatabase } from '../storage/db';
Expand All @@ -23,7 +23,7 @@
decryptString: (b) => b.toString('utf8'),
};

function buildHarness() {
function buildHarness(overrides: Record<string, unknown> = {}) {
const handlers = new Map<string, (...a: unknown[]) => unknown>();
const progressEvents: { channel: string; payload: unknown }[] = [];
const ipcMain: IpcMainLike = {
Expand All @@ -43,6 +43,7 @@
selectDirectory: vi.fn().mockResolvedValue('/picked/dir'),
appVersion: '1.2.3',
openExternal: vi.fn().mockResolvedValue(undefined),
...overrides,
};
registerIpc(ipcMain, deps);
return { handlers, deps, progressEvents };
Expand All @@ -58,21 +59,21 @@

it('shell:openExternal opens http(s) urls', async () => {
const { handlers, deps } = buildHarness();
const res = await handlers.get(CH.openExternal)!('https://github.com/facebook/react');

Check warning on line 62 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Forbidden non-null assertion
expect(res).toEqual({ ok: true, data: true });
expect(deps.openExternal).toHaveBeenCalledWith('https://github.com/facebook/react');
});

it('shell:openExternal rejects non-http schemes', async () => {
const { handlers, deps } = buildHarness();
const res = (await handlers.get(CH.openExternal)!('file:///etc/passwd')) as { ok: boolean };

Check warning on line 69 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Forbidden non-null assertion
expect(res.ok).toBe(false);
expect(deps.openExternal).not.toHaveBeenCalled();
});

it('accounts:create stores account + secret and returns the account', async () => {
const { handlers, deps } = buildHarness();
const res = (await handlers.get(CH.accountsCreate)!({

Check warning on line 76 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Forbidden non-null assertion
label: 'AWS', provider: 'amazon-s3', region: 'eu-central-1', accessKeyId: 'AK', secretAccessKey: 'SK',
})) as { ok: boolean; data: { id: string } };
expect(res.ok).toBe(true);
Expand All @@ -82,11 +83,11 @@

it('accounts:update changes fields and keeps the secret when none is given', async () => {
const { handlers, deps } = buildHarness();
const created = (await handlers.get(CH.accountsCreate)!({

Check warning on line 86 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Forbidden non-null assertion
label: 'AWS', provider: 'amazon-s3', region: 'eu-central-1', accessKeyId: 'AK', secretAccessKey: 'SK',
})) as { data: { id: string } };

const res = (await handlers.get(CH.accountsUpdate)!({

Check warning on line 90 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Forbidden non-null assertion
id: created.data.id, label: 'AWS renamed', provider: 'amazon-s3',
region: 'us-east-1', accessKeyId: 'AK2',
})) as { ok: boolean; data: { label: string; region: string } };
Expand All @@ -99,7 +100,7 @@

it('accounts:update replaces the secret when one is provided', async () => {
const { handlers, deps } = buildHarness();
const created = (await handlers.get(CH.accountsCreate)!({

Check warning on line 103 in src/main/ipc/register.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Forbidden non-null assertion
label: 'AWS', provider: 'amazon-s3', region: 'eu-central-1', accessKeyId: 'AK', secretAccessKey: 'SK',
})) as { data: { id: string } };

Expand Down Expand Up @@ -479,7 +480,7 @@
it('settings:get returns the default and settings:set persists a new value', async () => {
const { handlers } = buildHarness();
const before = (await handlers.get(CH.getSettings)!()) as { ok: boolean; data: { presignExpirySeconds: number } };
expect(before).toEqual({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system', language: 'system' } });
expect(before).toEqual({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null } });

const saved = (await handlers.get(CH.setSettings)!({ presignExpirySeconds: 86400 })) as { ok: boolean; data: { presignExpirySeconds: number } };
expect(saved.data.presignExpirySeconds).toBe(86400);
Expand All @@ -496,6 +497,22 @@
expect(res.ok).toBe(true);
expect(res.data).toEqual({ version: '1.2.3', encryptionAvailable: true, accountCount: 0 });
});

it('app:checkForUpdate reports a newer release as available', async () => {
const fetchImpl = vi.fn().mockResolvedValue({
status: 200,
ok: true,
json: async () => ({ tag_name: 'v9.9.9', html_url: 'https://example/release' }),
});
const { handlers } = buildHarness({ fetchImpl });
const res = (await handlers.get(CH.checkForUpdate)!()) as {
ok: boolean;
data: { updateAvailable: boolean; latestVersion: string };
};
expect(res.ok).toBe(true);
expect(res.data.updateAvailable).toBe(true);
expect(res.data.latestVersion).toBe('9.9.9');
});
});

describe('object ACL handlers', () => {
Expand Down
6 changes: 6 additions & 0 deletions src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { ObjectAcl } from '../s3/objectAcl';
import { getEditableMetadata, updateObjectMetadata } from '../s3/objectMetadata';
import { createFolder, moveObject, moveFolder } from '../s3/transfer';
import { createBucket } from '../s3/buckets';
import { checkForUpdate } from '../update/checkForUpdate';
import { planSync, runSync, type Endpoint } from '../s3/sync';
import { planLocalSync, runLocalSync } from '../s3/localSync';
import type { LocalSyncArgs } from '../s3/localSync';
Expand Down Expand Up @@ -56,6 +57,8 @@ export interface RegisterDeps {
appVersion: string;
/** Opens a URL in the user's default browser (Electron shell.openExternal), injected by main.ts. */
openExternal: (url: string) => Promise<void>;
/** Fetch implementation for the update check; defaults to globalThis.fetch. Injectable for tests. */
fetchImpl?: typeof fetch;
/** Applies the chosen theme to native chrome (nativeTheme.themeSource), injected by main.ts. Optional so tests/headless can omit it. */
applyTheme?: (theme: AppSettings['theme']) => void;
}
Expand Down Expand Up @@ -387,4 +390,7 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void {
accountCount: deps.accounts.list().length,
}),
);
h(CH.checkForUpdate, () =>
checkForUpdate({ fetchImpl: deps.fetchImpl ?? globalThis.fetch, currentVersion: deps.appVersion }),
);
}
34 changes: 30 additions & 4 deletions src/main/settings/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ function fakeRepo() {

describe('readSettings', () => {
it('returns the default expiry when unset', () => {
expect(readSettings(fakeRepo())).toEqual({ presignExpirySeconds: 3600, theme: 'system', language: 'system' });
expect(readSettings(fakeRepo())).toEqual({ presignExpirySeconds: 3600, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null });
});

it('returns a valid stored value', () => {
const repo = fakeRepo();
repo.set('presignExpirySeconds', '86400');
expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system' });
expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null });
});

it('falls back to the default for a non-numeric or out-of-range stored value', () => {
Expand All @@ -30,8 +30,8 @@ describe('writeSettings', () => {
it('persists a value and returns the merged settings', () => {
const repo = fakeRepo();
const out = writeSettings(repo, { presignExpirySeconds: 86400 });
expect(out).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system' });
expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system' });
expect(out).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null });
expect(readSettings(repo)).toEqual({ presignExpirySeconds: 86400, theme: 'system', language: 'system', autoCheckUpdates: true, lastUpdateCheckAt: null });
});

it('clamps to the [1, 604800] range', () => {
Expand Down Expand Up @@ -88,3 +88,29 @@ describe('language', () => {
expect(writeSettings(repo, { language: 'bogus' as never }).language).toBe('fr');
});
});

describe('update-check settings', () => {
function fresh() {
const m = new Map<string, string>();
return { get: (k: string) => m.get(k), set: (k: string, v: string) => { m.set(k, v); } };
}

it('defaults autoCheckUpdates to true and lastUpdateCheckAt to null', () => {
const s = readSettings(fresh());
expect(s.autoCheckUpdates).toBe(true);
expect(s.lastUpdateCheckAt).toBeNull();
});

it('persists autoCheckUpdates=false', () => {
const repo = fresh();
expect(writeSettings(repo, { autoCheckUpdates: false }).autoCheckUpdates).toBe(false);
expect(readSettings(repo).autoCheckUpdates).toBe(false);
});

it('persists a numeric lastUpdateCheckAt and ignores invalid values', () => {
const repo = fresh();
expect(writeSettings(repo, { lastUpdateCheckAt: 1700000000000 }).lastUpdateCheckAt).toBe(1700000000000);
repo.set('lastUpdateCheckAt', 'nonsense');
expect(readSettings(repo).lastUpdateCheckAt).toBeNull();
});
});
20 changes: 19 additions & 1 deletion src/main/settings/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface AppSettings {
presignExpirySeconds: number;
theme: ThemePreference;
language: LanguagePreference;
autoCheckUpdates: boolean;
lastUpdateCheckAt: number | null;
}
export interface AppInfo {
version: string;
Expand Down Expand Up @@ -35,7 +37,12 @@ export function readSettings(repo: SettingsRepo): AppSettings {
const theme: ThemePreference = isTheme(storedTheme) ? storedTheme : 'system';
const storedLanguage = repo.get('language');
const language: LanguagePreference = isLanguage(storedLanguage) ? storedLanguage : 'system';
return { presignExpirySeconds, theme, language };
const storedAuto = repo.get('autoCheckUpdates');
const autoCheckUpdates = storedAuto === undefined ? true : storedAuto === 'true';
const storedLast = repo.get('lastUpdateCheckAt');
const lastN = storedLast !== undefined ? Number(storedLast) : NaN;
const lastUpdateCheckAt = Number.isFinite(lastN) && lastN >= 0 ? lastN : null;
return { presignExpirySeconds, theme, language, autoCheckUpdates, lastUpdateCheckAt };
}

export function writeSettings(repo: SettingsRepo, patch: Partial<AppSettings>): AppSettings {
Expand All @@ -49,5 +56,16 @@ export function writeSettings(repo: SettingsRepo, patch: Partial<AppSettings>):
if (patch.language !== undefined && isLanguage(patch.language)) {
repo.set('language', patch.language);
}
if (patch.autoCheckUpdates !== undefined) {
repo.set('autoCheckUpdates', String(Boolean(patch.autoCheckUpdates)));
}
if (
patch.lastUpdateCheckAt !== undefined &&
patch.lastUpdateCheckAt !== null &&
Number.isFinite(patch.lastUpdateCheckAt) &&
patch.lastUpdateCheckAt >= 0
) {
repo.set('lastUpdateCheckAt', String(Math.round(patch.lastUpdateCheckAt)));
}
return readSettings(repo);
}
Loading