chore: release v0.4.27

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-05-09 22:48:07 +07:00
parent b184444f34
commit b39eb61c33
24 changed files with 517 additions and 229 deletions

View File

@@ -46,12 +46,11 @@ export default function Sidebar({ onClose }) {
const [updateInfo, setUpdateInfo] = useState(null);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [updateStatus, setUpdateStatus] = useState(null);
const [shutdownCountdown, setShutdownCountdown] = useState(0);
const [enableTranslator, setEnableTranslator] = useState(false);
const { copied, copy } = useCopyToClipboard(2000);
const INSTALL_CMD = UPDATER_CONFIG.installCmd;
const STATUS_URL = `http://127.0.0.1:${UPDATER_CONFIG.statusPort}/update/status`;
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
useEffect(() => {
fetch("/api/settings")
@@ -75,40 +74,37 @@ export default function Sidebar({ onClose }) {
return pathname.startsWith(href);
};
const handleUpdate = async () => {
setIsUpdating(true);
// Open manual update panel (no countdown yet — user must click Copy to trigger shutdown)
const handleUpdate = () => {
setShowUpdateModal(false);
try {
const res = await fetch("/api/version/update", { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.message || "Update failed. Please run the install command manually.");
setIsUpdating(false);
return;
}
setIsDisconnected(true);
} catch (e) {
setIsDisconnected(true);
}
setIsUpdating(true);
};
// Poll updater status server while updating (Next server is dead, updater.js is alive)
useEffect(() => {
if (!isUpdating || !isDisconnected) return;
let stopped = false;
const tick = async () => {
try {
const res = await fetch(STATUS_URL, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
if (!stopped) setUpdateStatus(data);
}
} catch { /* updater not ready yet or finished */ }
};
tick();
const id = setInterval(tick, UPDATER_CONFIG.statusPollIntervalMs);
return () => { stopped = true; clearInterval(id); };
}, [isUpdating, isDisconnected, STATUS_URL]);
// Triggered by Copy button inside ManualUpdatePanel: copy + countdown + shutdown
const handleCopyAndShutdown = async () => {
try { await navigator.clipboard.writeText(INSTALL_CMD); } catch { /* clipboard blocked */ }
copy(INSTALL_CMD);
let remaining = UPDATER_CONFIG.shutdownCountdownSec;
setShutdownCountdown(remaining);
const timer = setInterval(() => {
remaining -= 1;
setShutdownCountdown(remaining);
if (remaining <= 0) {
clearInterval(timer);
fetch("/api/version/shutdown", { method: "POST" }).catch(() => {});
setIsDisconnected(true);
}
}, 1000);
};
const handleCancelUpdate = () => {
setIsUpdating(false);
setShutdownCountdown(0);
};
// Note: legacy updater poll removed. New flow: copy install cmd + shutdown server,
// user runs the command manually in another terminal.
const handleShutdown = async () => {
setIsShuttingDown(true);
@@ -364,23 +360,24 @@ export default function Sidebar({ onClose }) {
onClose={() => setShowUpdateModal(false)}
onConfirm={handleUpdate}
title="Update 9Router"
message={`This will close 9Router and install v${updateInfo?.latestVersion || ""} in a separate window. Continue?`}
confirmText="Update"
message={`Show install command for v${updateInfo?.latestVersion || ""}? You can copy it and shutdown to install manually.`}
confirmText="Show Command"
cancelText="Cancel"
variant="primary"
loading={isUpdating}
/>
{/* Disconnected Overlay */}
{isDisconnected && (
{/* Disconnected / Updating Overlay */}
{(isDisconnected || isUpdating) && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-6">
{isUpdating ? (
<UpdateProgress
status={updateStatus}
<ManualUpdatePanel
latestVersion={updateInfo?.latestVersion}
installCmd={INSTALL_CMD}
copied={copied}
onCopy={() => copy(INSTALL_CMD)}
onCopyAndShutdown={handleCopyAndShutdown}
onCancel={handleCancelUpdate}
countdown={shutdownCountdown}
isDisconnected={isDisconnected}
/>
) : (
<div className="text-center p-8">
@@ -404,136 +401,61 @@ Sidebar.propTypes = {
onClose: PropTypes.func,
};
function UpdateProgress({ status, latestVersion, installCmd, copied, onCopy }) {
const phase = status?.phase || "connecting";
const done = status?.done === true;
const success = status?.success === true;
const attempt = status?.attempt || 0;
const maxRetries = status?.maxRetries || 0;
const logTail = status?.logTail || [];
const errorMsg = status?.error;
const steps = [
{ key: "stopped", label: "Stopped 9Router server", state: "done" },
{
key: "launched",
label: "Launched background installer",
state: status ? "done" : "active",
},
{
key: "waiting",
label: "Waiting for app processes to exit",
state: phase === "waitingForExit" ? "active" :
(status && phase !== "starting" ? "done" : "pending"),
},
{
key: "installing",
label: attempt > 1 ? `Installing v${latestVersion || "latest"} (attempt ${attempt}/${maxRetries})` : `Installing v${latestVersion || "latest"}`,
state: done ? (success ? "done" : "error") : (phase === "installing" ? "active" : "pending"),
},
{
key: "finished",
label: done && success ? "Installed — ready to restart" : "Waiting to finish",
state: done && success ? "done" : (done && !success ? "error" : "pending"),
},
];
function ManualUpdatePanel({ latestVersion, installCmd, copied, onCopyAndShutdown, onCancel, countdown, isDisconnected }) {
const isCountingDown = countdown > 0;
return (
<div className="w-full max-w-lg rounded-xl bg-neutral-900/95 border border-white/10 p-6 text-white">
<div className="flex items-center gap-3 mb-4">
<div className={cn(
"flex items-center justify-center size-11 rounded-full",
done && success ? "bg-green-500/20 text-green-400" :
done && !success ? "bg-red-500/20 text-red-400" :
"bg-blue-500/20 text-blue-400"
)}>
<span className={cn(
"material-symbols-outlined text-[24px]",
!done && "animate-spin"
)}>
{done && success ? "check_circle" : done && !success ? "error" : "progress_activity"}
</span>
<div className="flex items-center justify-center size-11 rounded-full bg-amber-500/20 text-amber-400">
<span className="material-symbols-outlined text-[24px]">content_copy</span>
</div>
<div>
<h2 className="text-lg font-semibold">
{done && success ? "Update Completed" : done && !success ? "Update Failed" : "Updating 9Router"}
</h2>
<h2 className="text-lg font-semibold">Update 9Router{latestVersion ? ` to v${latestVersion}` : ""}</h2>
<p className="text-xs text-white/60">
{done && success
? `Installed v${latestVersion || "latest"} successfully`
: done && !success
? (errorMsg || "Installation failed")
: `Installing v${latestVersion || "latest"} from npm...`}
{isDisconnected
? "Server stopped. Paste the command into a terminal to install."
: isCountingDown
? `Command copied. Server will stop in ${countdown}s...`
: "Click the button below to copy the install command and shutdown."}
</p>
</div>
</div>
{/* Timeline */}
<ul className="space-y-2 mb-4">
{steps.map((s) => (
<li key={s.key} className="flex items-center gap-3 text-sm">
<span className={cn(
"material-symbols-outlined text-[18px] shrink-0",
s.state === "done" && "text-green-400",
s.state === "active" && "text-blue-400 animate-pulse",
s.state === "error" && "text-red-400",
s.state === "pending" && "text-white/30"
)}>
{s.state === "done" ? "check_circle" :
s.state === "error" ? "cancel" :
s.state === "active" ? "radio_button_checked" : "radio_button_unchecked"}
</span>
<span className={cn(
s.state === "pending" ? "text-white/40" : "text-white/90"
)}>{s.label}</span>
</li>
))}
</ul>
<p className="text-sm text-white/80 mb-2">Install command:</p>
<div className="w-full px-3 py-2 rounded bg-white/5 mb-4">
<code className="text-xs font-mono text-amber-400 break-all">{installCmd}</code>
</div>
{/* Log tail */}
{logTail.length > 0 && (
<div className="rounded-md bg-black/50 border border-white/5 p-3 mb-4 max-h-40 overflow-auto">
<pre className="text-[11px] font-mono text-white/70 whitespace-pre-wrap break-all">
{logTail.join("\n")}
</pre>
</div>
)}
<ol className="text-xs text-white/70 space-y-1 list-decimal list-inside mb-4">
<li>Click <strong>Copy & Shutdown</strong> below.</li>
<li>Paste the command into your terminal and press Enter.</li>
<li>Run <code className="px-1 rounded bg-white/10 text-green-400">9router</code> again after install.</li>
</ol>
{/* Actions */}
{done && success ? (
<div className="space-y-2">
<p className="text-sm text-white/80">
Run <code className="px-1.5 py-0.5 rounded bg-white/10 text-green-400">9router</code> in your terminal to start the new version.
</p>
<Button variant="secondary" fullWidth onClick={() => globalThis.location.reload()}>
Reload Page
{isDisconnected ? (
<Button variant="secondary" fullWidth onClick={() => globalThis.location.reload()}>
Reload Page
</Button>
) : (
<div className="flex gap-2">
<Button variant="secondary" onClick={onCancel} disabled={isCountingDown}>
Cancel
</Button>
<Button variant="primary" fullWidth onClick={onCopyAndShutdown} disabled={isCountingDown}>
{copied ? "✓ Copied — shutting down..." : isCountingDown ? `Shutting down in ${countdown}s` : "Copy & Shutdown"}
</Button>
</div>
) : done && !success ? (
<div className="space-y-2">
<p className="text-sm text-white/80">Run the install command manually:</p>
<button
onClick={onCopy}
className="w-full text-left px-3 py-2 rounded bg-white/5 hover:bg-white/10 transition-colors"
>
<code className="text-xs font-mono text-amber-400">
{copied ? "✓ copied!" : installCmd}
</code>
</button>
</div>
) : (
<p className="text-xs text-white/50 text-center">
This may take 30-60 seconds. Please don't close this window.
</p>
)}
</div>
);
}
UpdateProgress.propTypes = {
status: PropTypes.object,
ManualUpdatePanel.propTypes = {
latestVersion: PropTypes.string,
installCmd: PropTypes.string.isRequired,
copied: PropTypes.bool,
onCopy: PropTypes.func.isRequired,
onCopyAndShutdown: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
countdown: PropTypes.number,
isDisconnected: PropTypes.bool,
};

View File

@@ -16,6 +16,8 @@ export const GITHUB_CONFIG = {
export const UPDATER_CONFIG = {
npmPackageName: "9router",
installCmd: "npm i -g 9router",
installCmdLatest: "npm i -g 9router@latest --prefer-online",
shutdownCountdownSec: 3,
exitDelayMs: 500,
statusPort: 20129,
statusPollIntervalMs: 1000,
@@ -23,8 +25,8 @@ export const UPDATER_CONFIG = {
installRetries: 3,
installRetryDelayMs: 5000,
lingerAfterDoneMs: 30000,
waitForExitMinMs: 3000,
waitForExitMaxMs: 15000,
waitForExitMinMs: 5000,
waitForExitMaxMs: 20000,
waitForExitCheckMs: 500,
appPort: 20128,
};

View File

@@ -4,7 +4,7 @@ import { isCloudEnabled } from "@/lib/localDb";
const INTERNAL_BASE_URL =
process.env.BASE_URL ||
process.env.NEXT_PUBLIC_BASE_URL ||
"http://127.0.0.1:20128";
"http://localhost:20128";
/**
* Cloud sync scheduler