Compare commits

...

68 Commits

Author SHA1 Message Date
陈大猫
733e36a728 Merge pull request #314 from penguinway/feat/auto-update-unified
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
LGTM! 经过多轮 review 和修复,代码质量已经很好。

核心改进:
- 统一 useUpdateCheck 作为跨窗口的单一更新状态源
- 多窗口 IPC 广播(broadcastToAllWindows)
- 启动检查竞态缓解(8s delay + onUpdateAvailable/NotAvailable 取消)
- dismissed version 在 renderer 侧完整支持
- electron-updater fallback(GitHub API 不可用时)
- _isChecking 标记防止并发 checkForUpdates 调用

感谢合并!
2026-03-11 20:34:20 +08:00
bincxz
35174246cc fix(update): handle checking sentinel, restore dismiss for unsupported platforms
- Handle { checking: true } response from bridge.checkForUpdate()
  separately instead of treating it as "no update" — an in-flight
  check will resolve via IPC events
- Restore dismissUpdate() in "View in Settings" toast onClick so
  unsupported-platform users can suppress the notification; on
  supported platforms the Settings window picks up download state
  via IPC events independently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:24:54 +08:00
bincxz
ab13670eaa fix(update): sync dismissed ref for late windows, clear error on fallback success
- Set dismissedAutoDownloadRef when hydration skips a dismissed version
  so subsequent IPC events (progress/downloaded) are also suppressed
- When GitHub API fails but electron-updater fallback finds no update,
  clear manualCheckStatus from 'error' to 'up-to-date' instead of
  leaving Settings stuck in error state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:18:50 +08:00
bincxz
4f3e39e378 fix(update): fall back to electron-updater when GitHub API fails
When checkNow's GitHub API call fails (blocked/rate-limited), still
trigger electron-updater's checkForUpdate as a fallback. This restores
the update path for environments where api.github.com is unreachable
but the updater feed is still accessible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:12:18 +08:00
bincxz
2281d1df68 fix(update): don't throttle GitHub fallback from updater not-available
Remove STORAGE_KEY_UPDATE_LAST_CHECK write from onUpdateNotAvailable
handler — it would prevent the GitHub API fallback from running on app
restart, hiding releases that exist on GitHub but aren't yet in the
electron-updater feed. Let performCheck write the timestamp instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:05:47 +08:00
bincxz
e570185e2f fix(update): don't dismiss when navigating to Settings from toast
- Remove dismissUpdate() from "View in Settings" toast onClick — writing
  to STORAGE_KEY_UPDATE_DISMISSED_VERSION would cause the Settings window
  hydration to skip download state, making it appear idle
- Remove unused dismissUpdate import from App.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:58:33 +08:00
bincxz
12884165b5 fix(update): preserve GitHub fallback on not-available, recheck on reschedule
- Don't cancel startup GitHub API fallback when electron-updater says
  not-available — the GitHub release may exist before updater feed
  assets are published, and the fallback provides manual download link
- Rescheduled fallback now re-queries getUpdateStatus to avoid duplicate
  notifications on very slow networks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:52:04 +08:00
bincxz
11f82defc3 fix(update): use dismissed ref instead of idle check, preserve GitHub fallback
- Replace autoDownloadStatus==='idle' guard in progress/downloaded/error
  callbacks with a dedicated dismissedAutoDownloadRef to distinguish
  "dismissed version" from "not hydrated yet" in late-opening windows
- Don't clear hasUpdate on update-not-available — GitHub release may
  exist even when electron-updater feed says no compatible update,
  preserving the manual download fallback path
- Reset dismissedAutoDownloadRef on manual retry via checkNow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:45:24 +08:00
bincxz
ac9175b770 fix(update): reschedule fallback on in-flight check, clear stale state
- When the startup fallback sees the main process check still in flight,
  reschedule after 5s instead of permanently skipping — handles the case
  where the auto-check fails silently (check-phase errors not broadcast)
- onUpdateNotAvailable: clear hasUpdate and manualCheckStatus to remove
  stale "update available" state from earlier GitHub API checks, since
  the updater feed is authoritative on supported platforms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:38:02 +08:00
bincxz
71c6f68934 fix(update): honor dismissed version in manual check, clear isChecking safely
- checkNow: check dismissed version before marking status as 'available'
  to prevent re-downloading a release the user explicitly skipped
- startAutoCheck: verify updater exists before setting _isChecking flag
  to avoid permanent stuck state when electron-updater fails to load
- Clear _isChecking in all catch paths to prevent stuck state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:32:57 +08:00
bincxz
01bee794ee fix(update): track in-flight check state to prevent concurrent races
- Add _isChecking flag in autoUpdateBridge to track whether
  checkForUpdates is in flight; return sentinel when manual check
  arrives during an active auto-check instead of starting a concurrent
  call that electron-updater would reject
- Include isChecking in getUpdateStatus snapshot so the renderer can
  query it before starting the GitHub API fallback
- Startup fallback now checks getUpdateStatus().isChecking to skip
  when electron-updater is still checking on slow networks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:23:53 +08:00
bincxz
29dc01306d fix(update): make auto-check cancellable, persist no-update check time
- Store startAutoCheck timer ID so it can be cancelled; cancel it when
  the renderer triggers a manual checkForUpdate to avoid concurrent
  electron-updater calls that produce false errors
- Record lastCheckedAt and STORAGE_KEY_UPDATE_LAST_CHECK when
  update-not-available fires so the throttle works on the common
  no-update path and "Last checked" UI shows correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:17:53 +08:00
bincxz
0dcfd1489b fix(update): eliminate redundant startup check, serialize manual checks
- Broadcast 'update-not-available' from electron-updater to renderer so
  the startup GitHub API check is cancelled when no update exists
- Cancel pending startup timeout in checkNow() to prevent racing with
  electron-updater's startAutoCheck (concurrent calls cause false errors)
- Add onUpdateNotAvailable bridge event (preload + global.d.ts types)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:09:48 +08:00
bincxz
72f61141c4 fix(update): respect dismissed version in hydration, don't cache failed checks
- getUpdateStatus hydration: skip restoring download state for dismissed
  versions so late-opening windows don't show dismissed release UI
- performCheck: only advance lastCheck timestamp and cache release data
  on successful checks — failed checks no longer suppress re-checks for
  an hour while leaving stale cached release visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:03:15 +08:00
bincxz
37150ea379 fix(update): suppress download UI for dismissed versions, cancel racy startup check
- onUpdateAvailable: skip autoDownloadStatus→'downloading' transition when
  version is dismissed, preventing download progress/ready toasts
- onUpdateAvailable: cancel pending startup GitHub API check timeout to
  eliminate race where electron-updater is still checking at 8s
- onUpdateDownloadProgress/Downloaded/Error: suppress state transitions
  when autoDownloadStatus is 'idle' (dismissed version background download)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:55:51 +08:00
bincxz
5706af3f33 fix(update): don't falsely report up-to-date, fix stale hydration
- Return idle instead of up-to-date for dev/invalid builds in
  checkNow to avoid false positive status
- Replace stale cached release in getUpdateStatus hydration when
  the snapshot reports a different version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:45:18 +08:00
bincxz
6871c82ab8 fix(update): semantic version compare, restore dismiss, show fallback on error
- Use semantic version comparison for cached release hydration to
  avoid false positives when running a newer build than latest release
- Restore dismissUpdate() in startup toast so unsupported-platform
  users can silence repeated notifications
- Remove dismissed-version check from ready-to-install toast since
  dismissing availability should not block the install prompt
- Show manual download link in Settings on check errors too

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:38:10 +08:00
bincxz
b90ff692eb fix(update): hydrate cached release for late windows, fix dismiss flow
- Persist latestRelease to localStorage so windows opened after the
  initial check can hydrate release info without re-fetching
- Remove dismissUpdate() from "View in Settings" toast click — the
  dismissed version key was preventing the later install-ready toast
- Hydrate cached release data when startup check is throttled so
  Settings windows show the already-found update

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:31:23 +08:00
bincxz
ce71725dba fix(update): handle unsupported platforms, remove auto-check, update badge
- Don't treat unsupported auto-update platforms as download errors;
  keep autoDownloadStatus at idle so manual download link shows
- Remove auto-check on SettingsApplicationTab mount to avoid
  implicitly triggering downloads when opening Settings
- Update Application tab badge to reflect download/ready state
  instead of always showing "Download Now"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:25:32 +08:00
bincxz
fb5c4aaa58 fix(update): update latestRelease on version mismatch, surface check failures
- Replace stale latestRelease when electron-updater reports a different
  version than the cached GitHub API result
- Surface checkForUpdate() failures by setting autoDownloadStatus to
  error instead of silently dropping them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:15:34 +08:00
bincxz
45c059ae53 fix(update): suppress install toast for dismissed releases, reset stale status
- Check dismissed version before showing ready-to-install toast so
  users who skipped a release are not re-prompted after restart
- Reset _lastStatus on update-not-available so late-opening windows
  don't hydrate stale error/ready state from a previous check cycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:09:23 +08:00
bincxz
1d67eb40c4 fix(update): remove stale autoDownloadStatus check from manual update handler
The autoDownloadStatus read from updateState was captured at render
time, so after checkNow() resolves it still shows 'idle' even when
electron-updater has already started downloading. Remove the
openReleasePage() call entirely — checkNow() already triggers
electron-updater on supported platforms, and SettingsSystemTab shows
a manual download link on unsupported platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:02:12 +08:00
bincxz
c6e3989a1b fix(auto-update): restrict error broadcasts to download phase only
- Remove hasUpdate gate from ready-to-install toast so dismissing
  availability notification doesn't prevent restart prompt
- Only open releases page on platforms without auto-download
- Increase startup check delay to 8s to let electron-updater fire first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:57:35 +08:00
bincxz
ace081414f fix(review): skip redundant startup check, honor dismissed version in toasts
- Skip renderer's GitHub API startup check if electron-updater's
  auto-download has already started, preventing duplicate toast
  notifications for the same release
- Set hasUpdate in onUpdateAvailable IPC handler, checking dismissed
  version so that dismissed releases don't trigger the persistent
  "restart now" toast after auto-download completes
- Guard "ready to install" toast with hasUpdate check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:49:25 +08:00
bincxz
049a609bca fix(review): fix retry path, consolidate useUpdateCheck, show startup updates
- Fix autoDownloadStatusRef stale read during checkNow retry: eagerly
  sync the ref when resetting error->idle so checkForUpdate() fires
- Refactor SettingsApplicationTab to accept update props instead of
  creating its own useUpdateCheck instance, preventing duplicate checks
  and inconsistent state between Application and System tabs
- Show startup-detected updates (hasUpdate) in System tab, not only
  manualCheckStatus=available, so Linux/unsupported platforms see the
  update and manual download button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:43:34 +08:00
bincxz
44409e6d32 fix(review): hydrate update status for late-opening windows, fix toast race
- Add getUpdateStatus IPC handler so windows opened after download started
  can immediately reflect the current state instead of showing stale 'idle'
- Track _lastStatus in main process across all updater events
- Hydrate autoDownloadStatus on useUpdateCheck mount via getUpdateStatus()
- Fix toast race: use ref to track previous autoDownloadStatus so ready/error
  toasts only fire on actual status transitions, not when unrelated callback
  references change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:37:15 +08:00
bincxz
5246489ef9 fix(review): remove stale getSenderWindow reference from JSDoc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:26:14 +08:00
bincxz
83d0d917ad fix(review): guard formatLastChecked against negative timestamps
Handle clock skew (timestamp in the future) by treating negative
diff as "just now" instead of displaying negative time values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:25:43 +08:00
bincxz
73557d0af1 fix(review): remove dead code, fix gitignore pattern, correct changelog
- Remove unused getSenderWindow() from autoUpdateBridge (replaced by broadcastToAllWindows)
- Fix .gitignore: /CLAUDE.md instead of CLAUDE.md to only match root
- Merge duplicate [Unreleased] sections in CHANGELOG.md
- Correct checkNow description: uses GitHub API, then triggers electron-updater async

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:23:47 +08:00
penguinway
aa67455c8c fix(auto-update): restrict error broadcasts to download phase only
Add _isDownloading flag to track whether a download is in progress.
Set true on update-available (autoDownload=true starts download immediately),
reset on update-downloaded or error.

In the error handler, only broadcast netcatty:update:error when _isDownloading
is true — check-phase errors (e.g. startup network failures) are logged to
console only and do not set autoDownloadStatus in the renderer, preventing
false 'download failed' states when no download was ever attempted.
2026-03-11 17:09:31 +08:00
penguinway
c7d2482996 fix(update): classify checkNow error when result.error is set
performCheck returns a non-null UpdateCheckResult with error populated
on GitHub API/network failures. Extend the status derivation to treat
result.error as an error state instead of falling through to up-to-date.
2026-03-11 17:08:31 +08:00
penguinway
d2391f5472 fix(update): restore checkNow return type and add error state retry
P1: change checkNow return type from Promise<null> to Promise<UpdateCheckResult | null>
and return actual result so callers can read hasUpdate/latestRelease.

P2: reset autoDownloadStatus from 'error' to 'idle' when user triggers manual
check, enabling a retry path; also show Check for Updates button in error state.
2026-03-11 16:58:21 +08:00
penguinway
9be84c71f5 feat(auto-update): improve UX — auto-reset badge, trigger download, show last checked time
- Fix 1: when manual check finds update and electron-updater hasn't started
  downloading yet (autoDownloadStatus=idle), fire-and-forget checkForUpdate()
  to kick off the auto-download pipeline without blocking the UI

- Fix 2: manualCheckStatus='up-to-date' now auto-resets to 'idle' after 5s
  so the badge doesn't stay stale until the next check; any new check cancels
  the pending timer first

- Fix 3: SettingsSystemTab shows "last checked: X min ago" below the update
  section using lastCheckedAt from updateState; new i18n keys added for both
  zh-CN and en locales (lastCheckedJustNow, lastCheckedMinutesAgo,
  lastCheckedHoursAgo, lastCheckedPrefix)

Internal: add autoDownloadStatusRef and manualCheckResetTimeoutRef to
useUpdateCheck for reliable cross-closure state access and timer lifecycle.
2026-03-11 16:08:32 +08:00
penguinway
effb98b91a chore: gitignore local dev files (.serena/, VS build scripts, Directory.Build.*) 2026-03-11 16:06:05 +08:00
陈大猫
77fd7a42a8 fix(sftp): drag-upload goes to wrong directory after navigation (#311)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix(sftp): update currentPath immediately on navigation to prevent stale upload target

When navigating directories without a cache hit, currentPath was only
updated after the async file listing completed. If a drag-and-drop upload
occurred during or shortly after this window, getActivePane would return
the old currentPath, causing files to upload to the previous directory.

Now currentPath is updated immediately when loading begins, ensuring
upload operations always target the correct directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): revert currentPath to previous value when navigation fails

Address review feedback: if the directory listing throws a non-session
error, restore currentPath to its previous value so later operations
(e.g. uploads) don't target a path that was never successfully loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): clear files when entering loading state to prevent stale interactions

Address P1 review: the loading overlay is pointer-events-none, so users
could still interact with old files during navigation. Since currentPath
is now updated immediately, actions like delete/rename would resolve
against the new path but display old files. Clear files and selection
when loading begins to eliminate this inconsistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): restore previous files when reverting path on navigation error

Address P2 review: since files are now cleared when loading begins,
a failed navigation would leave the pane with the old path but an
empty file list. Save and restore the previous files alongside the
previous path in the error handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): restore selected files when reverting on navigation error

Address P2 review: save and restore selectedFiles alongside path and
files in the error handler so users don't lose their selection when
a navigation attempt fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): restore tab state when navigation is superseded by another request

Address P1 review: navSeqRef is tracked per-side not per-tab, so a
navigation from a different tab on the same side can invalidate this
request. When the sequence check causes an early return, restore this
tab's previous path, files, and selection instead of leaving it with
cleared files and a stale loading state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): avoid overwriting newer navigation state when superseded

When a navigation request is superseded by a newer one on the same tab
(e.g., fast A→B→C), the completing request should not blindly restore
its previous state, as that would overwrite the latest navigation's
optimistic update. Now we check if the tab's current path still matches
what this request set before restoring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): use per-tab request ID to guard superseded navigation restores

Replace the ambiguous currentPath equality check with a per-tab
navigation request ID (tabNavSeqRef). The old check failed when
refresh() triggered a navigation to the same path — the stale request
would incorrectly match and restore previous state.

The new approach tracks the latest requestId per tab, so:
- Same-tab superseded navigations (including same-path refreshes)
  correctly skip the restore.
- Cross-tab superseded navigations (different tab on the same side)
  correctly restore the orphaned tab's state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): track per-tab nav sequence to prevent cache-hit state overwrite

When a cache-miss request (A) is pending and a cache-hit request (B) runs
on the same tab, A's superseded handler could overwrite B's result because
it only checked path equality. Add tabNavSeqRef to track the latest
requestId per tab, so superseded requests correctly skip restore when
a newer navigation (including cache hits) has already handled the tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove leftover merge conflict markers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): restore to last confirmed state instead of optimistic state

When multiple navigations are in flight (A→B→C), the second navigation
would snapshot the optimistic state (path=B, files=[]) as its "previous"
state. If it then failed or was superseded, it would restore to an empty
file list instead of the last successfully loaded directory.

Introduce lastConfirmedRef to track the last known-good state per tab,
updated only on successful navigation (cache hit or listing success).
Restore-on-error and restore-on-supersede now always revert to this
confirmed state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): guard restores against stale connection after reconnect/disconnect

connect() and disconnect() reuse the same tab ID but bump navSeqRef
without updating tabNavSeqRef, so a pending navigation could restore
stale state from a previous host into a freshly reconnected tab.

Fix by:
- Capturing connectionId at navigation start and checking it in every
  updateTab restore callback (prev.connection?.id !== connectionId)
- Storing connectionId in lastConfirmedRef and re-seeding confirmed
  state when the connection changes, preventing old host data from
  being used as the restore target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): keep files visible during loading and re-seed confirmed state

Two UI regressions fixed:

1. After a file mutation (delete/create/rename), lastConfirmedRef still
   held the pre-mutation snapshot. If the subsequent refresh failed, the
   error handler would restore stale files (e.g. resurrecting deleted
   items). Fix: re-seed confirmed state from the pane whenever it is
   settled (not loading), capturing any optimistic mutation updates.

2. Clearing files to [] on navigation start left a tab blank when
   superseded by another tab navigating on the same side. Fix: keep
   existing files visible during loading — the loading overlay already
   has pointer-events-none to prevent interaction. Files are replaced
   on success or restored from lastConfirmedRef on error/supersede.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sftp): block interaction with stale files during directory loading

The loading overlay used pointer-events-none, allowing clicks to pass
through to stale file rows underneath. Since currentPath is updated
immediately on navigation, interacting with old filenames during a slow
load would resolve paths against the new directory.

Remove pointer-events-none from the loading overlay so it properly
blocks all interaction with the stale file list while loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: ignore .claude/ directory in eslint config

The .claude/worktrees/ directory contains full repo copies from agent
worktrees. ESLint was scanning these, causing 621 pre-existing errors
(no-undef for Node.js globals in .cjs files) that blocked npm run dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:05:41 +08:00
penguinway
a86a5e6839 fix(auto-update): revert checkNow to use performCheck (GitHub API) instead of electron-updater IPC
checkNow was calling bridge.checkForUpdate() which invokes updater.checkForUpdates()
via IPC. startAutoCheck() in the main process already calls checkForUpdates() on a
5s timer, and if that network request is still pending, the concurrent IPC call from
checkNow hangs indefinitely, causing the UI to be stuck in "checking" state forever.

Per the original design spec, checkNow should use performCheck() (GitHub API) directly.
This is completely independent of electron-updater's internal state machine, so it
never conflicts with the background startAutoCheck(). performCheck handles isCheckingRef,
isChecking, hasUpdate, and latestRelease; checkNow only manages manualCheckStatus.
2026-03-11 15:46:05 +08:00
penguinway
7ed4940e18 docs: update CHANGELOG for auto-update unification 2026-03-11 15:32:39 +08:00
penguinway
410d1ef097 feat(settings): pass unified updateState and update actions to SettingsSystemTab 2026-03-11 15:28:41 +08:00
penguinway
c386ee2e2e refactor(settings): remove local update state from SettingsSystemTab, use unified updateState props 2026-03-11 15:26:16 +08:00
penguinway
4c08888b60 fix(auto-update): fix isCheckingRef conflict in checkNow fallback path and stale version closure
- Critical: In Linux fallback path, temporarily reset isCheckingRef before calling
  performCheck so its own guard can run (was silently returning null due to double-set)
- Critical: Replace updateState.currentVersion closure in checkNow with currentVersionRef
  to avoid reading stale '' version on early user click; remove from useCallback deps
- Important: Add explicit !result guard when bridge is unavailable, returning 'error'
  status instead of silently falling through to 'up-to-date'
2026-03-11 15:20:27 +08:00
penguinway
2ea4c88680 feat(auto-update): add manualCheckStatus to UpdateState, rewrite checkNow to use electron-updater IPC 2026-03-11 15:13:54 +08:00
penguinway
0ba75f9af0 fix(auto-update): broadcast IPC events to all windows instead of single window 2026-03-11 15:09:29 +08:00
penguinway
4610348b0d Merge branch 'feat/auto-update' 2026-03-11 14:39:22 +08:00
penguinway
8d11b71bc1 Merge branch 'main' of github.com:penguinway/Netcatty 2026-03-11 14:39:08 +08:00
penguinway
6683001032 chore: exclude tests/ and CLAUDE.md from eslint and gitignore 2026-03-11 14:28:46 +08:00
penguinway
3b313ff933 chore: gitignore local test suite (tests/, vitest.config.ts) 2026-03-11 14:08:06 +08:00
penguinway
eaa27461fa docs: add CHANGELOG for auto-update feature 2026-03-11 13:16:39 +08:00
penguinway
20b65366be chore: ignore dev-app-update.yml, revert forceDevUpdateConfig test flag 2026-03-11 13:12:39 +08:00
penguinway
b8c08ba3ca chore: ignore AI-generated docs (docs/superpowers/) 2026-03-11 12:54:11 +08:00
陈大猫
981c5de90d Merge pull request #310 from binaricat/fix/windows-auto-update-signing
fix: prevent macOS signing credentials from leaking to Windows builds
2026-03-11 11:40:58 +08:00
bincxz
0097d65a6e fix: prevent macOS signing credentials from leaking to Windows builds
Only pass CSC_LINK, CSC_KEY_PASSWORD, and Apple notarization secrets
to the macOS matrix job. Previously these were passed to all matrix
jobs, causing electron-builder to sign Windows .exe with the Apple
Developer ID certificate. Windows doesn't trust Apple's certificate
chain, so electron-updater's signature verification failed during
auto-update.

Closes #309
2026-03-11 11:15:04 +08:00
penguinway
e4aa03c474 fix(auto-update): use duration:0 for persistent toast, remove stale comment 2026-03-11 02:44:44 +08:00
penguinway
b94386236c feat(auto-update): add ready-to-install and download-failed toast notifications 2026-03-11 02:40:10 +08:00
penguinway
0883585704 feat(auto-update): add autoDownloadStatus state and IPC subscriptions to useUpdateCheck 2026-03-11 02:35:45 +08:00
penguinway
5b38f4663d feat(auto-update): add i18n keys for ready-to-install and download-failed toasts 2026-03-11 02:35:18 +08:00
penguinway
a6a6dd1aac feat(auto-update): expose onUpdateAvailable in preload bridge 2026-03-11 02:32:20 +08:00
penguinway
506c60ea44 feat(auto-update): trigger startAutoCheck after main window ready 2026-03-11 02:32:09 +08:00
penguinway
9d9b24fe7b feat(auto-update): add onUpdateAvailable type to NetcattyBridge 2026-03-11 02:32:06 +08:00
penguinway
584b9859ef fix(auto-update): guard setupGlobalListeners against duplicate registration 2026-03-11 02:30:52 +08:00
penguinway
b005065949 feat(auto-update): enable autoDownload and global IPC event listeners 2026-03-11 02:27:59 +08:00
penguinway
a4fdb6758d docs: add auto-update implementation plan
Detailed step-by-step plan for feat/auto-update branch. Addresses
reviewer feedback: specific line anchors, SettingsSystemTab props
pattern, removeAllListeners risk, i18n key conflict notes, and
hasUpdate toast suppression when auto-download is active.
2026-03-11 02:23:20 +08:00
penguinway
a2b5c9d067 docs: add auto-update design spec
Spec for changing update flow from manual to auto-download + prompt
install: autoDownload=true in main process, renderer subscribes to
electron-updater IPC events, toast notification on download complete.
2026-03-11 02:11:39 +08:00
陈大猫
a451fd8811 Merge pull request #308 from binaricat/fix/issue-307-display-upload-path
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix(sftp): display upload destination path on completed task items (#307)
2026-03-10 21:26:06 +08:00
bincxz
49cef792a8 fix(sftp): display upload destination path on completed task items (#307)
Show the remote target path inline on completed upload task items
(e.g. "Completed - 1.2 MB → /home/user/dir") so users know exactly
where their files were uploaded after drag-and-drop to terminal.

- Add `targetPath` field to modal's TransferTask type
- Populate targetPath from currentPath in onTaskCreated callback
- Display targetPath on completed upload items in SftpModalUploadTasks
- Add i18n key `sftp.upload.completedToPath` (en/zh-CN)
2026-03-10 21:14:25 +08:00
陈大猫
62511ceb21 Merge pull request #305 from binaricat/fix/sftp-mfa-auth-304
fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
2026-03-10 10:54:37 +08:00
bincxz
00cbb05d71 fix(sftp): handle end/close events during SSH connect phase
Address code review feedback: the direct ssh2.Client connect path
was missing end/close event handlers. If the server closes the
connection before 'ready' (e.g. rejected handshake, hop drops),
the promise now properly rejects instead of hanging forever.

Uses a settle/cleanup pattern to ensure listeners are removed and
the promise is resolved/rejected exactly once.
2026-03-10 10:40:47 +08:00
bincxz
3497614165 fix(sftp): fallback to standard SFTP when sudo sftp-server not found
When sudo SFTP fails with exit code 127 (sftp-server binary not found,
e.g. on ESXi), automatically fall back to the standard SFTP subsystem
channel instead of failing the entire connection. This avoids requiring
users to manually disable sudo mode for hosts that lack sftp-server.
2026-03-10 10:37:47 +08:00
bincxz
b652b836a7 fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
Two compounding issues caused SFTP connections to fail when
keyboard-interactive (MFA) authentication was required:

1. ssh2-sftp-client's connect() installs error listeners that reject
   the entire connection on ANY error, including non-fatal agent auth
   failures. This prevents ssh2 from falling through to
   keyboard-interactive. Fix: bypass ssh2-sftp-client's connect() and
   use direct ssh2.Client with err.level === 'agent' filtering.

2. getSshAgentSocket() on Windows unconditionally returned the agent
   pipe path without checking if the SSH Agent service is running.
   Fix: added async getAvailableAgentSocket() that runs
   'sc query ssh-agent' before returning the pipe path.
2026-03-10 10:12:37 +08:00
22 changed files with 1106 additions and 280 deletions

View File

@@ -59,12 +59,12 @@ jobs:
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
# macOS code signing & notarization (ignored on other platforms)
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# macOS code signing & notarization (only for macOS builds)
CSC_LINK: ${{ matrix.name == 'macos' && secrets.MAC_CSC_LINK || '' }}
CSC_KEY_PASSWORD: ${{ matrix.name == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
APPLE_ID: ${{ matrix.name == 'macos' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.name == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Upload artifacts

20
.gitignore vendored
View File

@@ -37,3 +37,23 @@ coverage
# Claude Code local settings
/.claude/settings.local.json
/CLAUDE.md
# AI / Superpowers generated docs (local only)
/docs/superpowers/
# Dev-only electron-updater test config (not for production)
/dev-app-update.yml
# Test suite (local only, not committed)
/tests/
/vitest.config.ts
# Serena MCP project config (local only)
/.serena/
# Windows VS Build environment scripts (local dev only)
Directory.Build.props
Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat

45
App.tsx
View File

@@ -304,13 +304,15 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, dismissUpdate } = useUpdateCheck();
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
// Show toast notification when update is available
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
@@ -320,13 +322,50 @@ function App({ settings }: { settings: SettingsState }) {
duration: 8000, // Show longer for update notifications
onClick: () => {
void openSettingsWindow();
// Dismiss the update so the toast doesn't re-fire on every render.
// On unsupported platforms (where autoDownloadStatus stays 'idle')
// this is the only way to suppress the notification for this version.
// On supported platforms this toast only shows before auto-download
// starts, and the Settings window's own useUpdateCheck will pick up
// the download state via IPC events independently of the dismiss.
dismissUpdate();
},
actionLabel: t('update.viewInSettings'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openSettingsWindow, dismissUpdate]);
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
const prev = prevAutoDownloadStatusRef.current;
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
if (prev === updateState.autoDownloadStatus) return;
if (updateState.autoDownloadStatus === 'ready') {
const version = updateState.latestRelease?.version ?? '';
toast.info(
t('update.readyToInstall.message', { version }),
{
title: t('update.readyToInstall.title'),
duration: 0,
actionLabel: t('update.restartNow'),
onClick: () => installUpdate(),
}
);
} else if (updateState.autoDownloadStatus === 'error') {
toast.error(
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.openReleases'),
onClick: () => openReleasePage(),
}
);
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
## [Unreleased] - 2026-03-11
### 功能
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
- 设置窗口中点击"检查更新"后,下载进度可实时反映在 UI 中
- 应用启动后 5 秒自动触发 `electron-updater` 检查更新,无需用户手动点击
- 发现新版本后自动开始下载(`autoDownload=true`
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
- 下载失败时弹出错误 toast提供"打开 Releases"降级入口
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
### 设计原理
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
- `manualCheckStatus` 字段追踪手动检查 UI 状态idle/checking/available/up-to-date/error`autoDownloadStatus` 在 UI 层按优先级渲染
- `SettingsSystemTab` 不再持有本地 update state单向接收 `useUpdateCheck` 统一数据
- 将原有两套独立系统GitHub API 通知 + electron-updater 手动下载)合并为统一状态机:`useUpdateCheck` 作为唯一事实来源,同时驱动 `App.tsx` toast 和 `SettingsSystemTab` 进度条
- 全局持久化 IPC 监听器在 `autoUpdateBridge.init()` 时一次性注册,避免每次手动下载请求重复注册/清理监听器
- `autoInstallOnAppQuit=false`,不做静默安装,由用户主动触发重启
### 接口变更SettingsSystemTabProps
- 移除:`autoDownloadStatus``downloadPercent`
- 新增:`updateState`(完整 UpdateState`checkNow``installUpdate``openReleasePage`
### 注意事项
- `checkNow` 语义:使用 GitHub API`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
- 此功能仅对打包后的应用Windows NSIS、macOS dmg/zip、Linux AppImage生效dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复

View File

@@ -109,6 +109,10 @@ const en: Messages = {
'settings.update.manualDownload': 'Download from GitHub',
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
'settings.update.lastCheckedJustNow': 'just now',
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -177,6 +181,12 @@ const en: Messages = {
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.readyToInstall.title': 'Update Ready',
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
'update.restartNow': 'Restart Now',
'update.downloadFailed.title': 'Update Failed',
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
'update.openReleases': 'Open Releases',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
@@ -728,6 +738,7 @@ const en: Messages = {
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',

View File

@@ -93,6 +93,10 @@ const zhCN: Messages = {
'settings.update.manualDownload': '前往 GitHub 下载',
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
'settings.update.lastCheckedJustNow': '刚刚',
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -161,6 +165,12 @@ const zhCN: Messages = {
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.viewInSettings': '在设置中查看',
'update.readyToInstall.title': '更新已就绪',
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
'update.restartNow': '立即重启',
'update.downloadFailed.title': '更新失败',
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
'update.openReleases': '打开 Releases',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
@@ -1053,6 +1063,7 @@ const zhCN: Messages = {
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
// SFTP Download
'sftp.download.completed': '已下载',

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
@@ -68,6 +68,18 @@ export const useSftpPaneActions = ({
isSessionError,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
// Track the latest navigation request ID per tab, so we can distinguish
// whether a superseded request was superseded by the same tab or a different tab.
const tabNavSeqRef = useRef(new Map<string, number>());
// Track the last confirmed (successfully loaded) state per tab, so that
// restore-on-error/supersede always reverts to a known-good state rather
// than an intermediate optimistic state from another in-flight navigation.
// Includes connectionId so stale entries from a previous host are ignored.
const lastConfirmedRef = useRef(
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
);
const navigateTo = useCallback(
async (
side: "left" | "right",
@@ -92,8 +104,9 @@ export const useSftpPaneActions = ({
return;
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
@@ -104,6 +117,13 @@ export const useSftpPaneActions = ({
cached.files
) {
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
tabNavSeqRef.current.set(activeTabId, requestId);
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path,
files: cached.files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
@@ -118,7 +138,36 @@ export const useSftpPaneActions = ({
}
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
// Re-seed confirmed state whenever the pane is settled (not loading), or
// when the connection has changed. This captures post-mutation state from
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
// doesn't resurrect deleted items.
const existing = lastConfirmedRef.current.get(activeTabId);
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path: pane.connection.currentPath,
files: pane.files,
selectedFiles: pane.selectedFiles,
});
}
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
const previousPath = confirmed.path;
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
tabNavSeqRef.current.set(activeTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
// that gets superseded by another tab navigating on the same side.
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
selectedFiles: new Set(),
loading: true,
error: null,
}));
try {
let files: SftpFileEntry[];
@@ -164,13 +213,42 @@ export const useSftpPaneActions = ({
}
}
if (navSeqRef.current[side] !== requestId) return;
if (navSeqRef.current[side] !== requestId) {
// Another navigation on this side superseded this request.
// Only restore if no newer navigation has occurred on this specific tab
// AND the tab still belongs to the same connection (connect/disconnect
// bump navSeqRef but not tabNavSeqRef).
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
// Tab was reconnected or disconnected; don't restore stale state.
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
}
dirCacheRef.current.set(cacheKey, {
files,
timestamp: Date.now(),
});
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path,
files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
@@ -181,13 +259,38 @@ export const useSftpPaneActions = ({
selectedFiles: new Set(),
}));
} catch (err) {
if (navSeqRef.current[side] !== requestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
}));
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
};
});
}
},
[

View File

@@ -1,13 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// Delay startup check to avoid slowing down app launch
const STARTUP_CHECK_DELAY_MS = 5000;
// Delay startup check to avoid slowing down app launch.
// 8s gives electron-updater's startAutoCheck(5000) time to emit
// 'update-available' first. The `onUpdateAvailable` handler also cancels
// any pending startup timeout, so even on slow networks where the event
// arrives after 8s the duplicate check is avoided.
const STARTUP_CHECK_DELAY_MS = 8000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
@@ -19,6 +23,10 @@ const debugLog = (...args: unknown[]) => {
}
};
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
export interface UpdateState {
isChecking: boolean;
hasUpdate: boolean;
@@ -26,6 +34,12 @@ export interface UpdateState {
latestRelease: ReleaseInfo | null;
error: string | null;
lastCheckedAt: number | null;
// Auto-download state — driven by electron-updater IPC events
autoDownloadStatus: AutoDownloadStatus;
downloadPercent: number;
downloadError: string | null;
/** Manual check state — driven by user clicking "Check for Updates" */
manualCheckStatus: ManualCheckStatus;
}
export interface UseUpdateCheckResult {
@@ -33,6 +47,7 @@ export interface UseUpdateCheckResult {
checkNow: () => Promise<UpdateCheckResult | null>;
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
}
/**
@@ -49,11 +64,44 @@ export function useUpdateCheck(): UseUpdateCheckResult {
latestRelease: null,
error: null,
lastCheckedAt: null,
autoDownloadStatus: 'idle',
downloadPercent: 0,
downloadError: null,
manualCheckStatus: 'idle',
});
const hasCheckedOnStartupRef = useRef(false);
const isCheckingRef = useRef(false);
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track current version in a ref to avoid stale closure in checkNow
const currentVersionRef = useRef(updateState.currentVersion);
// Track autoDownloadStatus in a ref so checkNow always reads the latest value
const autoDownloadStatusRef = useRef<AutoDownloadStatus>('idle');
// Timer ref for auto-resetting manualCheckStatus='up-to-date' back to 'idle'
const manualCheckResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flag: true when we suppressed auto-download because the version was dismissed.
// Used to distinguish "idle because dismissed" from "idle because not hydrated yet"
// in the progress/downloaded/error callbacks.
const dismissedAutoDownloadRef = useRef(false);
// Keep currentVersionRef in sync so checkNow always reads the latest version
useEffect(() => {
currentVersionRef.current = updateState.currentVersion;
}, [updateState.currentVersion]);
// Keep autoDownloadStatusRef in sync so checkNow always reads the latest download state
useEffect(() => {
autoDownloadStatusRef.current = updateState.autoDownloadStatus;
}, [updateState.autoDownloadStatus]);
// Cleanup: clear any pending manualCheckStatus reset timer on unmount
useEffect(() => {
return () => {
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
}
};
}, []);
// Get current app version
useEffect(() => {
@@ -71,6 +119,136 @@ export function useUpdateCheck(): UseUpdateCheckResult {
void loadVersion();
}, []);
// Hydrate auto-download status from the main process so windows opened
// after the download started (e.g. Settings) immediately reflect the
// current state instead of showing stale 'idle'.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getUpdateStatus?.().then((snapshot) => {
if (!snapshot || snapshot.status === 'idle') return;
// Respect dismissed versions: if the user dismissed this release,
// don't surface download progress/ready state in late-opening windows.
// Also set the dismissed ref so subsequent IPC events are suppressed.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (snapshot.version && snapshot.version === dismissedVersion) {
dismissedAutoDownloadRef.current = true;
return;
}
setUpdateState((prev) => {
// Don't overwrite if the renderer already has a newer state
if (prev.autoDownloadStatus !== 'idle') return prev;
return {
...prev,
autoDownloadStatus: snapshot.status,
downloadPercent: snapshot.percent,
downloadError: snapshot.error,
// Use snapshot version if no release data or if versions differ
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
version: snapshot.version,
tagName: `v${snapshot.version}`,
name: `v${snapshot.version}`,
body: '',
htmlUrl: '',
publishedAt: new Date().toISOString(),
assets: [],
} : prev.latestRelease) : prev.latestRelease,
};
});
});
}, []);
// Subscribe to electron-updater auto-download IPC events.
// These fire automatically when autoDownload=true in the main process.
useEffect(() => {
const bridge = netcattyBridge.get();
// When electron-updater confirms no update in its feed, don't write
// STORAGE_KEY_UPDATE_LAST_CHECK — that would throttle the GitHub API
// fallback for an hour. Let performCheck write it on success so the
// GitHub check can still discover releases not yet in the updater feed.
const cleanupNotAvailable = bridge?.onUpdateNotAvailable?.(() => {
// No-op for now — the GitHub fallback will handle lastCheckedAt.
});
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
// Cancel any pending startup GitHub API check — electron-updater is
// now authoritative and we don't want a duplicate toast.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Check if this version was dismissed by the user
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isDismissed = dismissedVersion === info.version;
if (isDismissed) {
dismissedAutoDownloadRef.current = true;
}
setUpdateState((prev) => ({
...prev,
hasUpdate: !isDismissed,
// Only transition to 'downloading' if the user hasn't dismissed this
// version — otherwise leave the status at 'idle' so no download
// progress/ready toast appears for a release they don't want.
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
downloadPercent: isDismissed ? prev.downloadPercent : 0,
downloadError: isDismissed ? prev.downloadError : null,
// Use electron-updater's version if GitHub API hasn't resolved yet or
// if the updater reports a different version than the cached release.
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
version: info.version,
tagName: `v${info.version}`,
name: `v${info.version}`,
body: info.releaseNotes || '',
htmlUrl: '',
publishedAt: info.releaseDate || new Date().toISOString(),
assets: [],
} : prev.latestRelease,
}));
});
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
// If we suppressed the download for a dismissed version, ignore progress.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: Math.round(p.percent),
}));
});
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
// If the download was for a dismissed version, don't transition to
// 'ready' — that would trigger the "Update ready" toast.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'ready',
downloadPercent: 100,
}));
});
const cleanupError = bridge?.onUpdateError?.((payload) => {
// If we suppressed the download for a dismissed version, ignore errors.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: payload.error,
}));
});
return () => {
cleanupNotAvailable?.();
cleanupAvailable?.();
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
@@ -119,8 +297,16 @@ export function useUpdateCheck(): UseUpdateCheckResult {
debugLog('Latest release version:', result.latestRelease?.version);
const now = Date.now();
// Save last check time
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
// Only advance last-check time and cache release on successful checks.
// Failed checks (result.error set, no latestRelease) must not update
// the timestamp — otherwise stale cached release data persists for an
// hour while the throttle prevents re-checking.
if (!result.error) {
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
if (result.latestRelease) {
localStorageAdapter.writeString(STORAGE_KEY_UPDATE_LATEST_RELEASE, JSON.stringify(result.latestRelease));
}
}
// Check if this version was dismissed
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
@@ -156,11 +342,121 @@ export function useUpdateCheck(): UseUpdateCheckResult {
}
}, []);
const checkNow = useCallback(async () => {
// In demo mode, use fake version to allow checking
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
return performCheck(version);
}, [performCheck, updateState.currentVersion]);
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
// Prevent concurrent checks (performCheck owns isCheckingRef)
if (isCheckingRef.current) {
debugLog('checkNow: already checking, skipping');
return null;
}
// Cancel any pending startup auto-check to avoid racing with
// electron-updater's startAutoCheck — concurrent checkForUpdates()
// calls are rejected by electron-updater and would surface a false error.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Clear any pending "up-to-date" auto-reset timer
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
manualCheckResetTimeoutRef.current = null;
}
// Reset dismissed flag so a manual retry can surface download events again
dismissedAutoDownloadRef.current = false;
// Immediately reflect 'checking' in the UI; reset download error so the user can retry
setUpdateState((prev) => {
// Eagerly sync the ref so the checkForUpdate gate below reads the updated value
if (prev.autoDownloadStatus === 'error') {
autoDownloadStatusRef.current = 'idle';
}
return {
...prev,
manualCheckStatus: 'checking',
error: null,
// P2: reset download error state so auto-download can retry on next available update
autoDownloadStatus: prev.autoDownloadStatus === 'error' ? 'idle' : prev.autoDownloadStatus,
downloadError: prev.autoDownloadStatus === 'error' ? null : prev.downloadError,
};
});
// Skip check for dev/invalid builds (demo mode overrides to '0.0.1' inside performCheck)
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersionRef.current;
if (!effectiveVersion || effectiveVersion === '0.0.0') {
// Dev/invalid build — can't determine update status, reset to idle
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'idle',
}));
return null;
}
// Delegate to performCheck (GitHub API) — completely independent of
// electron-updater's startAutoCheck() in the main process.
// performCheck sets isCheckingRef, isChecking, hasUpdate, latestRelease.
const result = await performCheck(effectiveVersion);
// Determine manual check status. performCheck already suppressed dismissed
// versions in state (hasUpdate=false), so we must respect that here too —
// otherwise a dismissed release would be reported as 'available' and could
// trigger a background download via checkForUpdate below.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isAvailable = result !== null && !result.error && result.hasUpdate &&
result.latestRelease?.version !== dismissedVersion;
const nextStatus: ManualCheckStatus =
result === null || result.error ? 'error' : isAvailable ? 'available' : 'up-to-date';
setUpdateState((prev) => ({
...prev,
manualCheckStatus: nextStatus,
}));
if (nextStatus === 'up-to-date') {
// Auto-reset "up-to-date" badge back to idle after 5s
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
} else if ((nextStatus === 'available' || nextStatus === 'error') && autoDownloadStatusRef.current === 'idle') {
// Trigger electron-updater as a fallback. This covers two cases:
// 1. 'available': GitHub found an update but electron-updater hasn't
// started a download yet — kick it off.
// 2. 'error': GitHub API failed (blocked/rate-limited), but the
// electron-updater feed may still be reachable. Without this,
// environments where api.github.com is blocked would never attempt
// the auto-download path.
void netcattyBridge.get()?.checkForUpdate?.().then((res) => {
if (res?.error && res?.supported !== false) {
// Surface actual download-feed errors; unsupported platforms
// (res.supported === false) should keep autoDownloadStatus at
// 'idle' so the manual download link shows.
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error,
}));
} else if (res?.checking) {
// Another check is already in flight — don't change status; the
// in-flight check will resolve via IPC events.
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
// GitHub API failed but electron-updater says no update available.
// Clear the error status so Settings doesn't stay stuck in error state.
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'up-to-date',
}));
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
}
}).catch(() => {
// Bridge unavailable — ignore; the manual download link remains visible
});
}
return result;
}, [performCheck]);
const dismissUpdate = useCallback(() => {
if (updateState.latestRelease?.version) {
@@ -189,6 +485,10 @@ export function useUpdateCheck(): UseUpdateCheckResult {
window.open(url, '_blank', 'noopener,noreferrer');
}, [updateState.latestRelease]);
const installUpdate = useCallback(() => {
netcattyBridge.get()?.installUpdate?.();
}, []);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
@@ -238,13 +538,60 @@ export function useUpdateCheck(): UseUpdateCheckResult {
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
// Hydrate cached release info so late-opening windows show the result
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
if (cachedRelease) {
try {
const release = JSON.parse(cachedRelease) as ReleaseInfo;
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isNewer = updateState.currentVersion.localeCompare(release.version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
const showUpdate = isNewer && release.version !== dismissedVersion;
setUpdateState((prev) => ({
...prev,
latestRelease: prev.latestRelease ?? release,
hasUpdate: prev.hasUpdate || showUpdate,
lastCheckedAt: lastCheck,
}));
} catch {
// Ignore corrupted cache
}
}
return;
}
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(() => {
startupCheckTimeoutRef.current = setTimeout(async () => {
// If electron-updater's auto-check already started a download, skip the
// redundant GitHub API check to avoid duplicate toast notifications.
if (autoDownloadStatusRef.current !== 'idle') {
debugLog('Skipping startup check — auto-download already active');
return;
}
// If the main process check is still in flight, reschedule the
// fallback instead of permanently skipping it — the auto-check may
// fail silently (check-phase errors aren't broadcast to the renderer).
try {
const snapshot = await netcattyBridge.get()?.getUpdateStatus?.();
if (snapshot?.isChecking) {
debugLog('Main process check still in flight — rescheduling fallback');
startupCheckTimeoutRef.current = setTimeout(async () => {
if (autoDownloadStatusRef.current !== 'idle') return;
// Re-check if the main process check is still running to avoid
// duplicate notifications on very slow networks.
try {
const snap = await netcattyBridge.get()?.getUpdateStatus?.();
if (snap?.isChecking || (snap?.status && snap.status !== 'idle')) return;
} catch { /* fall through */ }
debugLog('=== Rescheduled fallback check triggered ===');
void performCheck(updateState.currentVersion);
}, 5000);
return;
}
} catch {
// Bridge unavailable — fall through to GitHub check
}
debugLog('=== Delayed check triggered ===');
void performCheck(updateState.currentVersion);
}, STARTUP_CHECK_DELAY_MS);
@@ -261,5 +608,6 @@ export function useUpdateCheck(): UseUpdateCheckResult {
checkNow,
dismissUpdate,
openReleasePage,
installUpdate,
};
}

View File

@@ -4,7 +4,7 @@ import AppLogo from "./AppLogo";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
import { SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
@@ -63,13 +63,18 @@ const ActionRow: React.FC<{
</button>
);
export default function SettingsApplicationTab() {
interface SettingsApplicationTabProps {
updateState: UpdateState;
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
const [hasAutoChecked, setHasAutoChecked] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -93,19 +98,6 @@ export default function SettingsApplicationTab() {
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Auto check for updates when entering this page
useEffect(() => {
if (hasAutoChecked) return;
if (updateState.isChecking) return;
// In demo mode or when we have a valid version, auto-check
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
if (!canCheck) return;
setHasAutoChecked(true);
void checkNow();
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -124,8 +116,9 @@ export default function SettingsApplicationTab() {
t('update.available.message', { version: result.latestRelease.version }),
t('update.available.title')
);
// Open the release page
openReleasePage();
// Don't auto-open the release page here — checkNow() already triggers
// electron-updater on supported platforms, and the Settings > System tab
// shows a "Manual Download" link on unsupported platforms.
} else if (result) {
setLastCheckResult('upToDate');
toast.success(
@@ -154,18 +147,25 @@ export default function SettingsApplicationTab() {
<span className="text-sm text-muted-foreground">
{appInfo.version ? appInfo.version : " "}
</span>
{/* Update available badge - inline with version */}
{updateState.hasUpdate && updateState.latestRelease && (
{/* Update badge - reflects auto-download state */}
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
<button
onClick={() => void openReleasePage()}
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
updateState.autoDownloadStatus === 'ready'
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
"transition-colors cursor-pointer"
)}
>
<ArrowUpCircle size={12} />
v{updateState.latestRelease.version} {t('update.downloadNow')}
v{updateState.latestRelease.version}{' '}
{updateState.autoDownloadStatus === 'ready'
? t('update.restartNow')
: updateState.autoDownloadStatus === 'downloading'
? `${updateState.downloadPercent}%`
: t('update.downloadNow')}
</button>
)}
</div>

View File

@@ -8,6 +8,7 @@ import { useSettingsState } from "../application/state/useSettingsState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
@@ -71,6 +72,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
@@ -165,7 +167,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
</div>
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
{mountedTabs.has("application") && <SettingsApplicationTab />}
{mountedTabs.has("application") && (
<SettingsApplicationTab
updateState={updateState}
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
/>
)}
{mountedTabs.has("appearance") && (
<SettingsAppearanceTab
@@ -237,6 +246,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
updateState={updateState}
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
/>
)}
</div>

View File

@@ -6,15 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
checkForUpdate,
downloadUpdate,
installUpdate,
onDownloadProgress,
onDownloaded,
onError as onUpdateError,
getReleasesUrl,
} from "../../../infrastructure/services/updateService";
import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
@@ -35,6 +27,22 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/** Returns a locale-agnostic relative time string for the given timestamp. */
function formatLastChecked(
timestamp: number | null,
t: (key: string) => string,
): string {
if (!timestamp) return '';
const diffMs = Date.now() - timestamp;
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
if (diffMins < 60)
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
const diffHours = Math.floor(diffMins / 60);
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
}
interface SettingsSystemTabProps {
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
@@ -47,6 +55,11 @@ interface SettingsSystemTabProps {
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
hotkeyRegistrationError: string | null;
// Unified update state — from useUpdateCheck hook in SettingsPageContent
updateState: UpdateState;
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -61,6 +74,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
updateState,
checkNow,
installUpdate,
openReleasePage,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
@@ -74,13 +91,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
// Software Update state
type UpdateStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'downloading' | 'ready' | 'error';
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [updateVersion, setUpdateVersion] = useState('');
const [updatePercent, setUpdatePercent] = useState(0);
const [updateError, setUpdateError] = useState('');
const [updateSupported, setUpdateSupported] = useState(true);
const [appVersion, setAppVersion] = useState('');
// Load app version on mount
@@ -93,63 +103,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
}
}, []);
// Subscribe to auto-update events
useEffect(() => {
const cleanupProgress = onDownloadProgress((p) => {
setUpdatePercent(Math.round(p.percent));
});
const cleanupDownloaded = onDownloaded(() => {
setUpdateStatus('ready');
});
const cleanupError = onUpdateError((payload) => {
setUpdateError(payload.error);
setUpdateStatus('error');
});
return () => {
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const handleCheckForUpdate = useCallback(async () => {
setUpdateStatus('checking');
setUpdateError('');
const result = await checkForUpdate();
if (result.error) {
setUpdateError(result.error);
setUpdateSupported(result.supported !== false);
setUpdateStatus('error');
} else if (result.available && result.version) {
setUpdateVersion(result.version);
setUpdateSupported(result.supported !== false);
setUpdateStatus('available');
} else {
setUpdateSupported(result.supported !== false);
setUpdateStatus('up-to-date');
}
}, []);
const handleDownloadUpdate = useCallback(async () => {
setUpdateStatus('downloading');
setUpdatePercent(0);
const result = await downloadUpdate();
if (!result.success) {
setUpdateError(result.error ?? t('settings.update.downloadError'));
setUpdateStatus('error');
}
// Success is handled by onDownloaded event
}, [t]);
const handleInstallUpdate = useCallback(() => {
installUpdate();
}, []);
const handleOpenReleases = useCallback(() => {
const url = updateVersion ? getReleasesUrl(updateVersion) : getReleasesUrl();
netcattyBridge.get()?.openExternal?.(url);
}, [updateVersion]);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
@@ -315,85 +268,99 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<span className="text-sm text-muted-foreground">
{t('settings.update.currentVersion')}
</span>
<span className="text-sm font-mono">{appVersion || '...'}</span>
<span className="text-sm font-mono">
{updateState.currentVersion || appVersion || '...'}
</span>
</div>
{/* Status message */}
{updateStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{updateStatus === 'available' && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace('{version}', updateVersion)}
</p>
)}
{updateStatus === 'downloading' && (
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
{updateState.autoDownloadStatus === 'downloading' && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t('settings.update.downloading').replace('{percent}', String(updatePercent))}
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
</p>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${updatePercent}%` }}
style={{ width: `${updateState.downloadPercent}%` }}
/>
</div>
</div>
)}
{updateStatus === 'ready' && (
{updateState.autoDownloadStatus === 'ready' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.readyToInstall')}
</p>
)}
{updateStatus === 'error' && (
{updateState.autoDownloadStatus === 'error' && (
<p className="text-sm text-destructive">
{updateError || t('settings.update.error')}
{updateState.downloadError || t('settings.update.error')}
</p>
)}
{/* Manual fallback hint when auto-update not supported */}
{!updateSupported && updateStatus !== 'idle' && (
<p className="text-sm text-muted-foreground">
{t('settings.update.manualDownloadHint')}
</p>
{updateState.autoDownloadStatus === 'idle' && (
<>
{updateState.manualCheckStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace(
'{version}',
updateState.latestRelease?.version ?? ''
)}
</p>
)}
{updateState.manualCheckStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.error || t('settings.update.error')}
</p>
)}
</>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-1">
{(updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error') && (
<Button
variant="outline"
size="sm"
onClick={handleCheckForUpdate}
disabled={updateStatus === 'checking'}
>
<RefreshCw size={14} className={cn('mr-1.5', updateStatus === 'checking' && 'animate-spin')} />
{updateStatus === 'checking' ? t('settings.update.checking') : t('settings.update.checkForUpdates')}
</Button>
)}
{updateStatus === 'checking' && (
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
<Button variant="outline" size="sm" disabled>
<RefreshCw size={14} className="mr-1.5 animate-spin" />
{t('settings.update.checking')}
</Button>
)}
{updateStatus === 'available' && updateSupported && (
<Button variant="default" size="sm" onClick={handleDownloadUpdate}>
<Download size={14} className="mr-1.5" />
{t('settings.update.download')}
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
/* Check button — shown in idle states and in error state (allows retry) */
<Button
variant="outline"
size="sm"
onClick={() => void checkNow()}
>
<RefreshCw size={14} className="mr-1.5" />
{t('settings.update.checkForUpdates')}
</Button>
)}
{updateStatus === 'ready' && (
<Button variant="default" size="sm" onClick={handleInstallUpdate}>
) : null}
{/* Install button — shown when download is complete */}
{updateState.autoDownloadStatus === 'ready' && (
<Button variant="default" size="sm" onClick={installUpdate}>
<RotateCcw size={14} className="mr-1.5" />
{t('settings.update.restartNow')}
</Button>
)}
{/* Manual fallback link — shown when unsupported, on error, or when update is available but unsupported */}
{((updateStatus === 'error') || (updateStatus === 'available' && !updateSupported)) && (
<Button variant="ghost" size="sm" onClick={handleOpenReleases}>
{/* Open releases — shown on download error */}
{updateState.autoDownloadStatus === 'error' && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
{/* Open releases — shown when update found on unsupported platform, or on check error */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
@@ -401,6 +368,13 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
</div>
<p className="text-xs text-muted-foreground">
{updateState.lastCheckedAt && (
<span>
{t('settings.update.lastCheckedPrefix')}
{formatLastChecked(updateState.lastCheckedAt, t)}
{' '}
</span>
)}
{t('settings.update.hint')}
</p>
</div>

View File

@@ -13,6 +13,7 @@ interface TransferTask {
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
targetPath?: string;
}
interface SftpModalUploadTasksProps {
@@ -166,6 +167,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
{task.targetPath && (
<span className="text-muted-foreground ml-1"> {task.targetPath}</span>
)}
</div>
)}
{task.status === "cancelled" && (

View File

@@ -27,6 +27,7 @@ interface TransferTask {
fileCount?: number;
completedCount?: number;
direction: "upload" | "download";
targetPath?: string;
}
// Keep UploadTask as alias for backwards compatibility
@@ -246,6 +247,7 @@ export const useSftpModalTransfers = ({
startTime: Date.now(),
isDirectory: task.isDirectory,
direction: "upload",
targetPath: currentPath,
};
setUploadTasks(prev => [...prev, uploadTask]);
},
@@ -343,7 +345,7 @@ export const useSftpModalTransfers = ({
);
},
};
}, [t]);
}, [t, currentPath]);
// Helper function to perform upload with compression setting from user preference
const performUpload = useCallback(async (

View File

@@ -411,7 +411,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{/* Loading overlay - covers entire pane when navigating directories */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}

View File

@@ -31,11 +31,27 @@ function isAutoUpdateSupported() {
/** Lazily resolved autoUpdater — avoids importing electron-updater in
* contexts where native modules might not be available. */
let _autoUpdater = null;
/** Guard against duplicate listener registration */
let _listenersRegistered = false;
/** Track whether a download is in progress to distinguish download errors from check errors */
let _isDownloading = false;
/** Track whether a checkForUpdates call is in flight (set before call, cleared on result event) */
let _isChecking = false;
/**
* Snapshot of the last known update status so newly opened windows can hydrate
* without waiting for the next IPC event.
* @type {{ status: 'idle' | 'downloading' | 'ready' | 'error', percent: number, error: string | null, version: string | null, isChecking: boolean }}
*/
let _lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
function getAutoUpdater() {
if (_autoUpdater) return _autoUpdater;
try {
const { autoUpdater } = require("electron-updater");
autoUpdater.autoDownload = false;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = false;
// Silence the default electron-log transport (we log ourselves).
autoUpdater.logger = null;
@@ -47,28 +63,156 @@ function getAutoUpdater() {
}
}
function init(deps) {
_deps = deps;
/**
* Register persistent global IPC event listeners for auto-download flow.
* Called once in init(). Forwards electron-updater events to the renderer
* even when no manual download was initiated.
*/
function setupGlobalListeners() {
if (_listenersRegistered) return;
const updater = getAutoUpdater();
if (!updater) return;
_listenersRegistered = true;
updater.on("update-not-available", () => {
_isChecking = false;
// Reset stale status so late-opening windows don't hydrate from a
// previous 'error' or 'ready' snapshot after a "no update" check.
_lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
broadcastToAllWindows("netcatty:update:update-not-available", {});
});
updater.on("update-available", (info) => {
_isChecking = false;
// autoDownload=true means the download begins immediately after this event
_isDownloading = true;
_lastStatus = { status: 'downloading', percent: 0, error: null, version: info.version || null, isChecking: false };
broadcastToAllWindows("netcatty:update:update-available", {
version: info.version || "",
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
releaseDate: info.releaseDate || null,
});
});
updater.on("download-progress", (info) => {
_lastStatus.percent = Math.round(info.percent ?? 0);
broadcastToAllWindows("netcatty:update:download-progress", {
percent: info.percent ?? 0,
bytesPerSecond: info.bytesPerSecond ?? 0,
transferred: info.transferred ?? 0,
total: info.total ?? 0,
});
});
updater.on("update-downloaded", () => {
_isDownloading = false;
_lastStatus = { ..._lastStatus, status: 'ready', percent: 100 };
broadcastToAllWindows("netcatty:update:downloaded");
});
updater.on("error", (err) => {
_isChecking = false;
// Only broadcast download-phase errors; check-phase errors (e.g. network failures
// during checkForUpdates) are not download failures and must not set autoDownloadStatus.
if (!_isDownloading) {
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Check-phase error (not broadcast to renderer):", err?.message || err);
return;
}
_isDownloading = false;
const errorMsg = err?.message || "Unknown update error";
_lastStatus = { ..._lastStatus, status: 'error', error: errorMsg };
broadcastToAllWindows("netcatty:update:error", {
error: errorMsg,
});
});
console.log("[AutoUpdate] Global listeners registered");
}
/** Get the focused or first available BrowserWindow to send events to. */
function getSenderWindow() {
/**
* Trigger an automatic update check after a delay.
* No-op on platforms that don't support auto-update (Linux deb/rpm/snap).
* Called from main process after the main window is created.
*
* @param {number} delayMs - Milliseconds to wait before checking (default: 5000)
*/
let _autoCheckTimer = null;
function startAutoCheck(delayMs = 5000) {
if (!isAutoUpdateSupported()) {
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
return;
}
_autoCheckTimer = setTimeout(async () => {
_autoCheckTimer = null;
const updater = getAutoUpdater();
if (!updater) {
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
return;
}
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
try {
console.log("[AutoUpdate] Starting automatic update check...");
await updater.checkForUpdates();
} catch (err) {
_isChecking = false;
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Auto-check failed:", err?.message || err);
}
}, delayMs);
}
/**
* Cancel a pending startAutoCheck timer. Called when the renderer triggers
* a manual check to avoid racing with the queued auto-check.
*/
function cancelAutoCheck() {
if (_autoCheckTimer) {
clearTimeout(_autoCheckTimer);
_autoCheckTimer = null;
}
}
function init(deps) {
_deps = deps;
setupGlobalListeners();
}
/**
* Broadcast an IPC event to all non-destroyed BrowserWindows.
* Ensures both the main window and settings window always receive
* auto-update events.
* @param {string} channel
* @param {unknown} [payload]
*/
function broadcastToAllWindows(channel, payload) {
try {
const { BrowserWindow } = _deps?.electronModule || {};
if (!BrowserWindow) return null;
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused;
const all = BrowserWindow.getAllWindows();
for (const win of all) {
if (!win.isDestroyed()) return win;
if (!BrowserWindow) return;
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed()) {
if (payload !== undefined) {
win.webContents.send(channel, payload);
} else {
win.webContents.send(channel);
}
}
}
} catch {}
return null;
} catch (err) {
console.warn("[AutoUpdate] broadcastToAllWindows failed:", err?.message || err);
}
}
function registerHandlers(ipcMain) {
// ---- Check for updates ------------------------------------------------
ipcMain.handle("netcatty:update:check", async () => {
// Cancel any pending auto-check to prevent concurrent checkForUpdates()
// calls — electron-updater rejects them and surfaces false errors.
cancelAutoCheck();
if (!isAutoUpdateSupported()) {
return {
available: false,
@@ -86,7 +230,16 @@ function registerHandlers(ipcMain) {
};
}
// If a check is already in flight (e.g. from startAutoCheck), don't
// start a concurrent one — electron-updater rejects it and surfaces a
// confusing error. Return a sentinel so the renderer knows to wait.
if (_isChecking) {
return { available: false, supported: true, checking: true };
}
try {
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
const result = await updater.checkForUpdates();
if (!result || !result.updateInfo) {
return { available: false, supported: true };
@@ -112,6 +265,8 @@ function registerHandlers(ipcMain) {
releaseDate: releaseDate || null,
};
} catch (err) {
_isChecking = false;
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Check failed:", err?.message || err);
return {
available: false,
@@ -127,65 +282,22 @@ function registerHandlers(ipcMain) {
if (!updater) {
return { success: false, error: "Update module not available." };
}
try {
// Capture the requesting window NOW so events always go back to the
// renderer that initiated the download, even if focus changes later.
const senderWindow = getSenderWindow();
// Wire progress events before starting the download.
const progressHandler = (info) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:download-progress", {
percent: info.percent ?? 0,
bytesPerSecond: info.bytesPerSecond ?? 0,
transferred: info.transferred ?? 0,
total: info.total ?? 0,
});
}
};
const downloadedHandler = () => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:downloaded");
}
// Cleanup one-shot listeners.
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
const errorHandler = (err) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:error", {
error: err?.message || "Download failed",
});
}
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
updater.on("download-progress", progressHandler);
updater.on("update-downloaded", downloadedHandler);
updater.on("error", errorHandler);
// Global listeners (registered in setupGlobalListeners) handle all
// progress/downloaded/error events. Just trigger the download.
await updater.downloadUpdate();
return { success: true };
} catch (err) {
// Clean up listeners to prevent leaks if downloadUpdate() rejects
// before the error event is emitted.
const updaterForCleanup = getAutoUpdater();
if (updaterForCleanup) {
updaterForCleanup.removeAllListeners("download-progress");
updaterForCleanup.removeAllListeners("update-downloaded");
updaterForCleanup.removeAllListeners("error");
}
console.error("[AutoUpdate] Download failed:", err?.message || err);
return { success: false, error: err?.message || "Download failed" };
}
});
// ---- Get current update status (for late-opening windows) ---------------
ipcMain.handle("netcatty:update:getStatus", () => {
return { ..._lastStatus };
});
// ---- Install (quit & install) ------------------------------------------
ipcMain.handle("netcatty:update:install", () => {
const updater = getAutoUpdater();
@@ -196,4 +308,4 @@ function registerHandlers(ipcMain) {
console.log("[AutoUpdate] Handlers registered");
}
module.exports = { init, registerHandlers, isAutoUpdateSupported };
module.exports = { init, registerHandlers, isAutoUpdateSupported, startAutoCheck };

View File

@@ -29,6 +29,7 @@ const {
applyAuthToConnOpts,
safeSend: authSafeSend,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getAvailableAgentSocket,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
@@ -427,7 +428,7 @@ function init(deps) {
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId, agentSocket) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -498,6 +499,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -521,6 +523,11 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
resolve();
});
conn.on('error', (err) => {
// Filter out non-fatal agent auth errors (same as in openSftp)
if (err.level === 'agent') {
console.log(`[SFTP Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
reject(err);
});
@@ -828,6 +835,10 @@ async function openSftp(event, options) {
let chainConnections = [];
let connectionSocket = null;
// Pre-fetch agent socket (async check for Windows SSH Agent service)
// This is used by both jump host chain auth and final host auth
const agentSocket = await getAvailableAgentSocket();
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
@@ -841,7 +852,8 @@ async function openSftp(event, options) {
jumpHosts,
options.hostname,
options.port || 22,
connId
connId,
agentSocket
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -895,6 +907,7 @@ async function openSftp(event, options) {
if (options.password) connectOpts.password = options.password;
// Build auth handler using shared helper
// Use pre-fetched agentSocket (validated async, including Windows service check)
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
@@ -903,6 +916,7 @@ async function openSftp(event, options) {
username: connectOpts.username,
logPrefix: "[SFTP]",
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connectOpts, authConfig);
@@ -922,44 +936,104 @@ async function openSftp(event, options) {
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
const sshClient = client.client;
// IMPORTANT: We bypass ssh2-sftp-client's connect() method and use the
// underlying ssh2 Client directly. This is because ssh2-sftp-client adds
// temporary error listeners that reject the entire connect promise on ANY
// error, including non-fatal auth errors (e.g. 'Failed to connect to agent'
// when ssh2 tries agent auth and falls through to the next method).
// By connecting directly, we can filter these non-fatal errors and allow
// the auth flow to continue to keyboard-interactive/password/etc.
const sshClient = client.client;
await new Promise((resolve, reject) => {
// Set up error handler for initial connection
const onConnectError = (err) => reject(err);
sshClient.once('error', onConnectError);
await new Promise((resolve, reject) => {
let settled = false;
const settle = (fn, val) => {
if (settled) return;
settled = true;
cleanup();
fn(val);
};
sshClient.once('ready', async () => {
sshClient.removeListener('error', onConnectError);
try {
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
const onError = (err) => {
// Filter out non-fatal authentication errors.
// ssh2 sets err.level = 'agent' when agent auth fails — it then
// internally calls tryNextAuth() to proceed with the next method.
// We must NOT reject here, or the fallback won't execute.
if (err.level === 'agent') {
console.log('[SFTP] Non-fatal agent auth error (will try next method):', err.message);
return;
}
settle(reject, err);
};
// Inject into sftp-client
client.sftp = sftpWrapper;
const onEnd = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
// Important: attach cleanup listener expected by sftp-client
client.sftp.on('close', () => client.end());
const onClose = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
const cleanup = () => {
sshClient.removeListener('error', onError);
sshClient.removeListener('end', onEnd);
sshClient.removeListener('close', onClose);
};
sshClient.on('error', onError);
sshClient.on('end', onEnd);
sshClient.on('close', onClose);
sshClient.once('ready', () => {
cleanup();
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
(async () => {
try {
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
client.sftp = sftpWrapper;
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
// Fallback: if sftp-server binary is missing (exit code 127),
// try standard SFTP subsystem instead of failing completely.
// This handles systems like ESXi that don't have sftp-server
// but support the SFTP subsystem natively.
if (e.message && e.message.includes('exit code 127')) {
console.warn('[SFTP] sftp-server not found, falling back to standard SFTP subsystem');
options.sudo = false; // Mark as non-sudo for downstream logic
sshClient.sftp((sftpErr, sftp) => {
if (sftpErr) {
sshClient.end();
return reject(sftpErr);
}
client.sftp = sftp;
resolve();
});
} else {
sshClient.end();
reject(e);
}
}
})();
} else {
// Open standard SFTP subsystem channel
sshClient.sftp((err, sftp) => {
if (err) return reject(err);
client.sftp = sftp;
resolve();
} catch (e) {
sshClient.end();
reject(e);
}
});
try {
sshClient.connect(connectOpts);
} catch (e) {
reject(e);
});
}
});
} else {
await client.connect(connectOpts);
}
try {
sshClient.connect(connectOpts);
} catch (e) {
settle(reject, e);
}
});
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit

View File

@@ -6,6 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { exec } = require("node:child_process");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
@@ -123,11 +124,33 @@ async function findAllDefaultPrivateKeys(options = {}) {
}
/**
* Get ssh-agent socket path based on platform
* Check if Windows SSH Agent service is running
* @returns {Promise<boolean>}
*/
function checkWindowsSshAgentRunning() {
return new Promise((resolve) => {
if (process.platform !== "win32") {
resolve(true);
return;
}
exec("sc query ssh-agent", (err, stdout) => {
if (err) {
resolve(false);
return;
}
resolve(stdout.includes("RUNNING"));
});
});
}
/**
* Get ssh-agent socket path based on platform (synchronous, best-effort)
* @returns {string|null}
*/
function getSshAgentSocket() {
if (process.platform === "win32") {
// On Windows, always return the pipe path; the caller should use
// getAvailableAgentSocket() for a reliable async check.
return "\\\\.\\pipe\\openssh-ssh-agent";
}
const agentSocket = process.env.SSH_AUTH_SOCK;
@@ -143,6 +166,18 @@ function getSshAgentSocket() {
}
}
/**
* Get ssh-agent socket path with async validation (checks Windows service status)
* @returns {Promise<string|null>}
*/
async function getAvailableAgentSocket() {
if (process.platform === "win32") {
const running = await checkWindowsSshAgentRunning();
return running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
}
return getSshAgentSocket();
}
/**
* Build authentication handler with default key fallback support
* @param {Object} options
@@ -156,7 +191,7 @@ function getSshAgentSocket() {
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
@@ -168,7 +203,10 @@ function buildAuthHandler(options) {
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
const sshAgentSocket = getSshAgentSocket();
// Allow callers to pass in a pre-validated agent socket (e.g. from async
// getAvailableAgentSocket). Fall back to synchronous getSshAgentSocket()
// which on Windows always returns the pipe path without checking the service.
const sshAgentSocket = sshAgentSocketOverride !== undefined ? sshAgentSocketOverride : getSshAgentSocket();
// Only use system ssh-agent BEFORE user's auth when:
// - User explicitly configured agent, OR
@@ -522,6 +560,7 @@ module.exports = {
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
getAvailableAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,

View File

@@ -794,7 +794,11 @@ if (!gotLock) {
});
// Create the main window
void createWindow().catch((err) => {
void createWindow().then(() => {
// Trigger auto-update check 5 s after window creation.
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
autoUpdateBridge.startAutoCheck(5000);
}).catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
try {

View File

@@ -16,6 +16,8 @@ const passphraseListeners = new Set();
const passphraseTimeoutListeners = new Set();
const updateDownloadProgressListeners = new Set();
const updateDownloadedListeners = new Set();
const updateAvailableListeners = new Set();
const updateNotAvailableListeners = new Set();
const updateErrorListeners = new Set();
function cleanupTransferListeners(transferId) {
@@ -135,6 +137,26 @@ ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
});
// Auto-update events
ipcRenderer.on("netcatty:update:update-available", (_event, payload) => {
updateAvailableListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("onUpdateAvailable callback failed", err);
}
});
});
ipcRenderer.on("netcatty:update:update-not-available", () => {
updateNotAvailableListeners.forEach((cb) => {
try {
cb();
} catch (err) {
console.error("onUpdateNotAvailable callback failed", err);
}
});
});
ipcRenderer.on("netcatty:update:download-progress", (_event, payload) => {
updateDownloadProgressListeners.forEach((cb) => {
try {
@@ -923,6 +945,15 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
onUpdateAvailable: (cb) => {
updateAvailableListeners.add(cb);
return () => updateAvailableListeners.delete(cb);
},
onUpdateNotAvailable: (cb) => {
updateNotAvailableListeners.add(cb);
return () => updateNotAvailableListeners.delete(cb);
},
onUpdateDownloadProgress: (cb) => {
updateDownloadProgressListeners.add(cb);
return () => updateDownloadProgressListeners.delete(cb);

View File

@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
},
{
files: ["**/*.{ts,tsx}"],

8
global.d.ts vendored
View File

@@ -622,12 +622,20 @@ declare global {
}>;
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
installUpdate?(): void;
getUpdateStatus?(): Promise<{ status: 'idle' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
onUpdateDownloadProgress?(cb: (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}) => void): () => void;
onUpdateAvailable?(cb: (info: {
version: string;
releaseNotes: string;
releaseDate: string | null;
}) => void): () => void;
onUpdateNotAvailable?(cb: () => void): () => void;
onUpdateDownloaded?(cb: () => void): () => void;
onUpdateError?(cb: (payload: { error: string }) => void): () => void;

View File

@@ -37,6 +37,7 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
// Update check
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';