fix(server): keep users' keys at their own endpoints, and more fixes from the third review

- Bedrock: a user's AWS keys no longer go to an endpoint the server sets
  in AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL (read by the
  upgraded SDK), and admin panel keys win over AWS_BEARER_TOKEN_BEDROCK,
  as the Test button checks them. Checked with Bedrock.
- Ollama: a server key without a base URL (admin panel, OLLAMA_API_KEY)
  goes to Ollama Cloud, as env.example says, instead of 127.0.0.1.
- Quota: EdgeOne counts whatever key header comes along, keyless Ollama at
  a private address counts, and their provider texts stay in the log.
- An EdgeOne server model (admin panel, ai-models.json) works: the route
  checked the raw provider header, which holds the name's slug.
- parse-url ends downloads it does not read (too large, PDF, errors).
- Desktop app: the port follows where the chats are (IndexedDB per
  origin) instead of a remembered port, which could hide them for good;
  a same-port restart tells the page to refetch the server models; a
  failed preset switch no longer undoes a newer choice; a presets file
  removed after a failed read can be saved again; .env values quoted from
  start to end keep their inner quotes, as dotenv reads them.
This commit is contained in:
dayuan.jiang
2026-10-05 13:04:51 +09:00
parent cfbf17f97e
commit 50c7ad3ec4
20 changed files with 466 additions and 160 deletions
+5
View File
@@ -70,6 +70,11 @@ declare global {
>
/** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
/**
* Call back after the server restarted on the same port (another
* preset); returns a function that stops the calls
*/
onServerRestarted?: (callback: () => void) => () => void
}
/** Settings window Electron API */
+7
View File
@@ -60,6 +60,13 @@ export async function switchPreset(
console.error("Failed to restart server:", error)
const reason = error instanceof Error ? error.message : String(error)
// Another preset was chosen meanwhile: its own restart follows
if (getCurrentPresetId() !== id) {
throw new Error(
`The server could not be restarted.\n\nError: ${reason}`,
)
}
// Revert to previous preset on failure
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
setCurrentPreset(null)
+2
View File
@@ -171,6 +171,8 @@ export function loadPresets(): ConfigPresetsFile {
const configPath = getConfigFilePath()
if (!existsSync(configPath)) {
// Nothing left that a save could overwrite
presetsUnreadable = false
return {
version: 1,
currentPresetId: null,
+5 -1
View File
@@ -51,7 +51,11 @@ function loadEnvFromFile(filePath: string): void {
const quote = value[0]
const closingQuote =
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
if (closingQuote > 0) {
if (value.length > 1 && value.endsWith(quote) && closingQuote > 0) {
// Quoted from start to end: the quotes inside belong to the
// value (JSON with an apostrophe), as dotenv reads it
value = value.slice(1, -1)
} else if (closingQuote > 0) {
// Quoted value: keep what's inside the quotes and drop
// anything after them (e.g. a comment)
value = value.slice(1, closingQuote)
-2
View File
@@ -6,7 +6,6 @@ import {
getAllocatedPort,
getServerUrl,
isPortAvailable,
saveServerPort,
} from "./port-manager"
import { setAppUrl } from "./window-manager"
@@ -145,7 +144,6 @@ async function startServer(): Promise<string> {
const url = getServerUrl()
await waitForServer(url)
console.log(`Next.js server started at ${url}`)
saveServerPort(port)
return url
}
+30 -86
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync } from "node:fs"
import { existsSync } from "node:fs"
import net from "node:net"
import path from "node:path"
import { app } from "electron"
@@ -26,84 +26,42 @@ const PORT_CONFIG = {
let allocatedPort: number | null = null
/**
* File that remembers the production port from the last launch, so the app
* keeps the same origin (and its localStorage) instead of switching between
* the legacy and new port depending on which one is free at startup
* Whether chats are saved under http://127.0.0.1:<port>: Electron keeps
* each origin's IndexedDB in its own folder
*/
function getSavedPortPath(): string {
return path.join(app.getPath("userData"), "server-port.json")
}
function loadSavedPort(): number | null {
try {
const { port } = JSON.parse(readFileSync(getSavedPortPath(), "utf-8"))
return Number.isInteger(port) ? port : null
} catch {
return null
}
function hasStoredData(port: number): boolean {
return existsSync(
path.join(
app.getPath("userData"),
"IndexedDB",
`http_127.0.0.1_${port}.indexeddb.leveldb`,
),
)
}
/**
* Why the legacy port was not used at this launch: "EACCES" when the system
* reserves it (Windows excludes port ranges for Hyper-V, which can change
* on each boot), "EADDRINUSE" when another process holds it for now
* Check if a specific port is available
*/
let legacyPortError: string | null = null
/**
* Remember the port of the first production launch. A later launch that
* found it taken keeps it remembered: the user's data lives under that
* origin, and the next launch goes back to it once it is free. Only the two
* fixed ports count, and 13370 only when the system reserves the legacy
* port: while another process holds it (such as the previous version still
* quitting after an update), the next launch tries it again.
*/
export function saveServerPort(port: number): void {
if (!app.isPackaged || loadSavedPort() !== null) {
return
}
const fixed =
port === PORT_CONFIG.legacyProduction ||
(port === PORT_CONFIG.production && legacyPortError === "EACCES")
if (!fixed) return
try {
writeFileSync(getSavedPortPath(), JSON.stringify({ port }), "utf-8")
} catch (error) {
console.error("Failed to save server port:", error)
}
}
/**
* Try to listen on a port. Resolves to null when it is free, else to the
* error code.
*/
function portError(port: number): Promise<string | null> {
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once("error", (err: NodeJS.ErrnoException) => {
console.warn(`Port ${port} unavailable: ${err.code}`)
resolve(err.code ?? "unknown")
resolve(false)
})
server.once("listening", () => {
server.close()
resolve(null)
resolve(true)
})
server.listen(port, "127.0.0.1")
})
}
/**
* Check if a specific port is available
*/
export async function isPortAvailable(port: number): Promise<boolean> {
return (await portError(port)) === null
}
/**
* Find an available port
* - In development: uses fixed port (6002)
* - In production: uses the port from the last launch, then the legacy
* port (61337), then 13370, to preserve localStorage
* - In production: uses the legacy port (61337), then 13370, to preserve
* localStorage; 13370 first when only it has saved chats
* - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0)
*
@@ -128,36 +86,22 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null
}
// In production, use the port from the last launch first
if (!isDev) {
const savedPort = loadSavedPort()
if (savedPort !== null) {
if (await isPortAvailable(savedPort)) {
allocatedPort = savedPort
return savedPort
}
console.warn(
`Port ${savedPort} from the last launch is unavailable. Data saved under it will not show on the new port.`,
)
// In production, try the legacy port first to preserve existing users'
// data, unless only the new port has data: their app started on 13370
// while Windows reserved 61337, and 61337 being free now would hide it
const candidates = isDev
? [preferredPort]
: hasStoredData(PORT_CONFIG.production) &&
!hasStoredData(PORT_CONFIG.legacyProduction)
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
for (const port of candidates) {
if (await isPortAvailable(port)) {
allocatedPort = port
return port
}
}
// In production, try legacy port first to preserve existing users' localStorage
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
legacyPortError = await portError(legacyPort)
if (legacyPortError === null) {
allocatedPort = legacyPort
return legacyPort
}
}
// Try preferred port
if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort
return preferredPort
}
console.warn(
`Preferred port ${preferredPort} is in use, finding alternative...`,
)
+4 -3
View File
@@ -107,12 +107,13 @@ export function getAppUrl(): string | null {
/**
* Point the main window at the restarted app server (it can come up on a
* different port). On the same port the page reloads, so it fetches the new
* preset's server models instead of sending the old preset's choice.
* different port). On the same port the page fetches the new preset's
* server models instead of sending the old preset's choice; it is not
* reloaded, which would drop unsent attachments.
*/
export function setAppUrl(url: string): void {
if (url === appUrl) {
mainWindow?.webContents.reload()
mainWindow?.webContents.send("server-restarted")
return
}
appUrl = url
+9
View File
@@ -27,4 +27,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
setUserLocale: (locale: string) =>
ipcRenderer.invoke("set-user-locale", locale),
// The server restarted on the same port (another preset)
onServerRestarted: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on("server-restarted", listener)
return () => {
ipcRenderer.removeListener("server-restarted", listener)
}
},
})