Compare commits

...

16 Commits

Author SHA1 Message Date
Syngnat
cecefce84d fix(update): keep script builder test compatibility 2026-07-07 21:35:02 +08:00
Syngnat
2406c1ff71 fix(update): relaunch versioned portable exe with new filename 2026-07-07 21:28:21 +08:00
Syngnat
fbb23f0b9a fix(update): import logger for cleanup launcher 2026-07-07 20:50:57 +08:00
Syngnat
016e2bc70e chore(update): remove unused cleanup imports 2026-07-07 20:46:14 +08:00
Syngnat
070c2328e0 test(update): cover Windows cleanup helpers 2026-07-07 20:43:40 +08:00
Syngnat
c117da42e4 fix(update): clean staged Windows packages and logs 2026-07-07 20:42:15 +08:00
Syngnat
950856d86f 合并拉取请求 #627
fix: improve OceanBase Oracle performance, navigation, and count UX
2026-07-07 17:51:42 +08:00
Syngnat
93a3240862 fix(query-editor): fallback ctrl-click for OB Oracle schema tables 2026-07-07 17:47:45 +08:00
Syngnat
1116a3736b fix(data-grid): bridge pagination count action to toolbar 2026-07-07 17:45:37 +08:00
Syngnat
19af57e46b chore: remove temporary patch workflow 2026-07-07 17:36:48 +08:00
Syngnat
401c33c505 chore: apply OB Oracle performance navigation count patches 2026-07-07 17:33:59 +08:00
Syngnat
8c968c1a87 feat(data-grid): expose total count action in pagination 2026-07-07 17:31:53 +08:00
Syngnat
4fee58999d perf(db): keep one idle Oracle-style connection 2026-07-07 17:29:17 +08:00
Syngnat
bd0752df96 test(oceanbase): keep Oracle default timeout unset 2026-07-07 17:28:57 +08:00
Syngnat
47a15a6674 fix(oceanbase): stop inflating Oracle query timeout by default 2026-07-07 17:28:10 +08:00
Syngnat
2a64bad76e fix(oceanbase): reduce Oracle long-query bad connection failures 2026-07-07 16:56:05 +08:00
7 changed files with 675 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button, InputNumber, Pagination, Select } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { CloseOutlined, LeftOutlined, RightOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import { t as defaultTranslate, type I18nParams } from '../i18n';
interface DataGridPaginationState {
@@ -26,12 +26,31 @@ export interface DataGridPaginationBarProps {
paginationPageText: string;
paginationPageSizeOptions: string[];
showKnownPageCount: boolean;
manualTotalCountAvailable?: boolean;
totalCountLoading?: boolean;
onPageChange?: (page: number, size: number) => void;
onPageSizeChange: (value: string) => void;
onV2PageStep: (direction: 'previous' | 'next') => void;
onToggleTotalCount?: () => void;
translate?: DataGridPaginationTranslate;
}
const findToolbarTotalCountButton = (
trigger: HTMLElement,
labels: string[],
): HTMLButtonElement | null => {
const root = trigger.closest('.data-grid-root') || trigger.ownerDocument?.body;
if (!root) return null;
const normalizedLabels = labels.map((label) => String(label || '').trim()).filter(Boolean);
const buttons = Array.from(root.querySelectorAll('button')) as HTMLButtonElement[];
return buttons.find((button) => {
if (button === trigger) return false;
if (button.disabled) return false;
const text = String(button.textContent || '').replace(/\s+/g, ' ').trim();
return normalizedLabels.some((label) => text === label || text.includes(label));
}) || null;
};
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
isV2Ui,
pagination,
@@ -42,9 +61,12 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
paginationPageText,
paginationPageSizeOptions,
showKnownPageCount,
manualTotalCountAvailable = false,
totalCountLoading = false,
onPageChange,
onPageSizeChange,
onV2PageStep,
onToggleTotalCount,
translate = defaultTranslate,
}) => {
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
@@ -58,6 +80,37 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
return null;
}
const countTotalLabel = translate('data_grid.toolbar.count_total');
const cancelCountLabel = translate('data_grid.toolbar.cancel_count');
const effectiveTotalCountLoading = totalCountLoading || Boolean(pagination.totalCountLoading);
const shouldShowTotalCountButton = Boolean(
onToggleTotalCount
|| manualTotalCountAvailable
|| pagination.totalCountLoading
|| pagination.totalKnown === false,
);
const handleToggleTotalCount = (event: React.MouseEvent<HTMLButtonElement>) => {
if (onToggleTotalCount) {
onToggleTotalCount();
return;
}
// Backward-compatible bridge for existing DataGridShell callers: the top toolbar already owns
// the total-count handler, but it can be horizontally scrolled out of view on large toolbars.
// Trigger that existing button so the pagination bar can expose the action without duplicating data-flow state.
const toolbarButton = findToolbarTotalCountButton(event.currentTarget, [countTotalLabel, cancelCountLabel]);
toolbarButton?.click();
};
const totalCountButton = shouldShowTotalCountButton ? (
<Button
data-grid-pagination-total-count="true"
size="small"
icon={effectiveTotalCountLoading ? <CloseOutlined /> : <VerticalAlignBottomOutlined />}
onClick={handleToggleTotalCount}
>
{effectiveTotalCountLoading ? cancelCountLabel : countTotalLabel}
</Button>
) : null;
const maxJumpPage = showKnownPageCount ? Math.max(1, paginationTotalPages) : null;
const normalizedJumpPage = Number.isFinite(Number(jumpPage)) && Number(jumpPage) > 0
? (maxJumpPage !== null
@@ -132,6 +185,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
<div className="data-grid-pagination-summary" aria-live="polite">
<span className="data-grid-pagination-summary-value">{paginationV2SummaryText}</span>
</div>
{totalCountButton}
<Button
data-grid-v2-pagination-prev="true"
size="small"
@@ -174,6 +228,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
<span className="data-grid-pagination-kicker">{translate('data_grid.pagination.result_set')}</span>
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
</div>
{totalCountButton}
{showSequentialPagination ? sequentialPaginationControl : (
<Pagination
current={pagination.current}

View File

@@ -24,6 +24,140 @@ const sameEditorPosition = (left: any, right: any): boolean => (
&& Number(left?.column) === Number(right?.column)
);
const stripSqlIdentifierQuotes = (value: string): string => {
const text = String(value || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`'))
|| (text.startsWith('"') && text.endsWith('"'))
|| (text.startsWith('[') && text.endsWith(']'))) {
return text.slice(1, -1).trim();
}
return text;
};
const splitSqlIdentifierPath = (raw: string): string[] => (
String(raw || '')
.split('.')
.map(stripSqlIdentifierQuotes)
.map((part) => part.trim())
.filter(Boolean)
);
const resolveIdentifierWindowAtColumn = (
lineContent: string,
column: number,
): { start: number; end: number; text: string } | null => {
const text = String(lineContent || '');
if (!text) return null;
const isIdentChar = (ch: string) => /[A-Za-z0-9_$`"\[\].]/.test(ch || '');
let offset = Math.max(0, Math.min(text.length - 1, Number(column || 1) - 2));
if (!isIdentChar(text[offset] || '')) {
if (offset > 0 && isIdentChar(text[offset - 1] || '')) {
offset -= 1;
} else if (offset + 1 < text.length && isIdentChar(text[offset + 1] || '')) {
offset += 1;
} else {
return null;
}
}
let start = offset;
while (start > 0 && isIdentChar(text[start - 1] || '')) start -= 1;
let end = offset + 1;
while (end < text.length && isIdentChar(text[end] || '')) end += 1;
return start < end ? { start, end, text: text.slice(start, end).trim() } : null;
};
const isLikelyTableReferenceIdentifier = (
lineContent: string,
identifierStart: number,
): boolean => {
const beforeIdentifier = String(lineContent || '').slice(0, Math.max(0, identifierStart));
return /\b(?:from|join|update|into|delete\s+from|alter\s+table|drop\s+table|truncate\s+table)\s*$/i.test(beforeIdentifier);
};
const isOceanBaseOracleConnection = (connection: any): boolean => {
const config = connection?.config || {};
return String(config.type || '').trim().toLowerCase() === 'oceanbase'
&& String(config.oceanBaseProtocol || '').trim().toLowerCase() === 'oracle';
};
const installOceanBaseOracleNavigationFallback = (editor: any) => {
const editorDomNode = editor?.getDomNode?.();
if (!editorDomNode || editor.__gonaviObOracleNavigationFallbackInstalled) {
return;
}
Object.defineProperty(editor, '__gonaviObOracleNavigationFallbackInstalled', {
value: true,
configurable: true,
});
const handleMouseDownCapture = (event: MouseEvent) => {
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) {
return;
}
const store = useStore.getState();
const activeTab = (store.tabs || []).find((tab: any) => tab.id === store.activeTabId);
if (!activeTab || activeTab.type !== 'query') {
return;
}
const connectionId = String(activeTab.connectionId || store.activeContext?.connectionId || '').trim();
if (!connectionId) {
return;
}
const connection = (store.connections || []).find((item: any) => item.id === connectionId);
if (!isOceanBaseOracleConnection(connection)) {
return;
}
const target = editor.getTargetAtClientPoint?.(event.clientX, event.clientY);
const position = target?.position;
if (!position) {
return;
}
const model = editor.getModel?.();
const lineContent = String(model?.getLineContent?.(position.lineNumber) || '');
const identifier = resolveIdentifierWindowAtColumn(lineContent, position.column);
if (!identifier || !identifier.text.includes('.')) {
return;
}
if (!isLikelyTableReferenceIdentifier(lineContent, identifier.start)) {
return;
}
const parts = splitSqlIdentifierPath(identifier.text);
if (parts.length !== 2) {
return;
}
const [schemaName, tableName] = parts;
if (!schemaName || !tableName) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
store.setActiveContext?.({ connectionId, dbName: schemaName });
store.addTab?.({
id: `${connectionId}-${schemaName}-table-${tableName}`,
title: tableName,
type: 'table',
connectionId,
dbName: schemaName,
tableName,
initialViewMode: 'fields',
initialViewModeRequestId: String(Date.now()),
objectType: 'table',
returnToTabId: activeTab.id || undefined,
});
};
editorDomNode.addEventListener('mousedown', handleMouseDownCapture, true);
editor.onDidDispose?.(() => {
editorDomNode.removeEventListener('mousedown', handleMouseDownCapture, true);
});
};
const patchQueryEditorAiInlineRightArrowFallback = (editor: any, monaco: any) => {
const originalAddCommand = editor?.addCommand?.bind?.(editor);
if (!originalAddCommand || !monaco?.KeyCode?.RightArrow) {
@@ -154,6 +288,7 @@ const MonacoEditor: React.FC<MonacoEditorProps> = ({
}, [beforeMount]);
const handleMount: OnMount = useCallback((editor, monaco) => {
installOceanBaseOracleNavigationFallback(editor);
patchQueryEditorAiInlineRightArrowFallback(editor, monaco);
onMount?.(editor, monaco);
}, [onMount]);

View File

@@ -9,8 +9,6 @@ import (
"GoNavi-Wails/internal/db"
)
const defaultOceanBaseOracleQueryTimeoutSeconds = 120
func normalizeRunConfig(config connection.ConnectionConfig, dbName string) connection.ConnectionConfig {
runConfig := config
name := strings.TrimSpace(dbName)
@@ -56,10 +54,6 @@ func applyOceanBaseOracleCurrentSchemaInit(config connection.ConnectionConfig, s
if normalizedSchema == "" {
return config
}
if config.Timeout <= 0 {
// OceanBase Oracle 查询经常需要经过 OBProxy/Oracle driver 的读等待;默认 30s 容易把慢查询误报成 driver: bad connection。
config.Timeout = defaultOceanBaseOracleQueryTimeoutSeconds
}
values, err := url.ParseQuery(strings.TrimSpace(config.ConnectionParams))
if err != nil {
return config

View File

@@ -20,8 +20,8 @@ func TestNormalizeRunConfig_OceanBaseOracleAddsCurrentSchemaInit(t *testing.T) {
if runConfig.Database != "OBORCL" {
t.Fatalf("expected OceanBase Oracle service name to stay OBORCL, got %q", runConfig.Database)
}
if runConfig.Timeout != defaultOceanBaseOracleQueryTimeoutSeconds {
t.Fatalf("expected OceanBase Oracle default query timeout %d, got %d", defaultOceanBaseOracleQueryTimeoutSeconds, runConfig.Timeout)
if runConfig.Timeout != 0 {
t.Fatalf("expected default OceanBase Oracle timeout to stay unset, got %d", runConfig.Timeout)
}
values, err := url.ParseQuery(runConfig.ConnectionParams)
if err != nil {

View File

@@ -0,0 +1,363 @@
package app
import (
"io"
"os"
"path/filepath"
stdRuntime "runtime"
"strconv"
"strings"
"GoNavi-Wails/internal/logger"
)
func init() {
updateLaunchInstallScript = launchUpdateScriptWithCleanup
}
func launchUpdateScriptWithCleanup(staged *stagedUpdate) error {
exePath, err := os.Executable()
if err != nil {
return err
}
exePath, _ = filepath.EvalSymlinks(exePath)
pid := os.Getpid()
if stdRuntime.GOOS == "windows" {
return launchWindowsUpdateWithCleanup(staged, exePath, pid)
}
return launchUpdateScript(staged)
}
func launchWindowsUpdateWithCleanup(staged *stagedUpdate, targetExe string, pid int) error {
if staged == nil {
return localizedUpdateError{key: "app.update.backend.message.no_downloaded_package"}
}
if err := os.MkdirAll(staged.StagedDir, 0o755); err != nil {
return err
}
currentTargetExe := strings.TrimSpace(targetExe)
originalSourceDir := strings.TrimSpace(filepath.Dir(staged.FilePath))
preparedSource, err := prepareWindowsStagedUpdateAsset(staged.FilePath, staged.StagedDir)
if err != nil {
return err
}
staged.FilePath = preparedSource
staged.InstallLogPath = buildUpdateInstallLogPath(staged.StagedDir)
finalTargetExe := resolveWindowsUpdateFinalTargetPath(currentTargetExe, staged.FilePath)
cleanupWindowsUpdateArtifacts([]string{
originalSourceDir,
strings.TrimSpace(filepath.Dir(currentTargetExe)),
strings.TrimSpace(filepath.Dir(finalTargetExe)),
}, map[string]struct{}{
cleanComparablePath(currentTargetExe): {},
cleanComparablePath(finalTargetExe): {},
cleanComparablePath(staged.FilePath): {},
cleanComparablePath(staged.StagedDir): {},
})
scriptPath := filepath.Join(staged.StagedDir, "update.cmd")
content := buildWindowsScriptWithCurrentTargetCleanup(staged.FilePath, finalTargetExe, currentTargetExe, staged.StagedDir, staged.InstallLogPath, pid)
if err := os.WriteFile(scriptPath, []byte(content), 0o644); err != nil {
return err
}
logger.Infof("启动 Windows 更新脚本current=%s target=%s script=%s log=%s", currentTargetExe, finalTargetExe, scriptPath, staged.InstallLogPath)
cmd := buildWindowsLaunchCommand(scriptPath)
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
if err := cmd.Process.Release(); err != nil {
logger.Warnf("释放 Windows 更新脚本进程句柄失败:%v", err)
}
}
return nil
}
func resolveWindowsUpdateFinalTargetPath(currentTarget string, sourcePath string) string {
currentTarget = strings.TrimSpace(currentTarget)
if currentTarget == "" {
return currentTarget
}
currentName := filepath.Base(currentTarget)
sourceName := filepath.Base(strings.TrimSpace(sourcePath))
if isVersionedWindowsUpdatePackageName(currentName) && isVersionedWindowsUpdatePackageName(sourceName) {
return filepath.Join(filepath.Dir(currentTarget), sourceName)
}
return currentTarget
}
func isVersionedWindowsUpdatePackageName(name string) bool {
trimmed := strings.TrimSpace(name)
lower := strings.ToLower(trimmed)
return strings.HasPrefix(trimmed, "GoNavi-")
&& strings.Contains(trimmed, "-Windows-")
&& strings.HasSuffix(lower, ".exe")
}
func prepareWindowsStagedUpdateAsset(sourcePath string, stagedDir string) (string, error) {
sourcePath = strings.TrimSpace(sourcePath)
stagedDir = strings.TrimSpace(stagedDir)
if sourcePath == "" || stagedDir == "" {
return sourcePath, nil
}
if isUpdateAssetPathInsideStagedDir(sourcePath, stagedDir) {
return sourcePath, nil
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
return "", err
}
targetPath := filepath.Join(stagedDir, filepath.Base(sourcePath))
if cleanComparablePath(sourcePath) == cleanComparablePath(targetPath) {
return sourcePath, nil
}
_ = os.Remove(targetPath)
if err := os.Rename(sourcePath, targetPath); err == nil {
return targetPath, nil
}
if err := copyFileForWindowsUpdate(sourcePath, targetPath); err != nil {
return "", err
}
_ = os.Remove(sourcePath)
return targetPath, nil
}
func copyFileForWindowsUpdate(sourcePath string, targetPath string) error {
in, err := os.Open(sourcePath)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
func cleanupWindowsUpdateArtifacts(dirs []string, keep map[string]struct{}) {
seen := map[string]struct{}{}
for _, dir := range dirs {
dir = strings.TrimSpace(dir)
if dir == "" || dir == "." {
continue
}
cleanDir := cleanComparablePath(dir)
if cleanDir == "" {
continue
}
if _, ok := seen[cleanDir]; ok {
continue
}
seen[cleanDir] = struct{}{}
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
cleanPath := cleanComparablePath(path)
if cleanPath == "" {
continue
}
if _, ok := keep[cleanPath]; ok {
continue
}
if shouldRemoveWindowsUpdateArtifact(entry.Name(), entry.IsDir()) {
if entry.IsDir() {
_ = os.RemoveAll(path)
} else {
_ = os.Remove(path)
}
}
}
}
}
func shouldRemoveWindowsUpdateArtifact(name string, isDir bool) bool {
trimmed := strings.TrimSpace(name)
lower := strings.ToLower(trimmed)
if trimmed == "" {
return false
}
if isDir {
return strings.HasPrefix(lower, ".gonavi-update-")
}
if strings.HasPrefix(lower, "gonavi-update-") && strings.HasSuffix(lower, ".log") {
return true
}
if !strings.HasPrefix(trimmed, "GoNavi-") {
return false
}
if !strings.Contains(trimmed, "-Windows-") {
return false
}
return strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".zip")
}
func cleanComparablePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
cleaned := filepath.Clean(path)
if cleaned == "." {
return ""
}
if stdRuntime.GOOS == "windows" {
return strings.ToLower(cleaned)
}
return cleaned
}
func buildWindowsScriptWithCleanup(source, target, stagedDir, logPath string, pid int) string {
return buildWindowsScriptWithCurrentTargetCleanup(source, target, target, stagedDir, logPath, pid)
}
func buildWindowsScriptWithCurrentTargetCleanup(source, target, currentTarget, stagedDir, logPath string, pid int) string {
script := `@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
set "TARGET=__GONAVI_UPDATE_TARGET__"
set "CURRENT_TARGET=__GONAVI_CURRENT_TARGET__"
set "TARGET_OLD=%TARGET%.old"
set "STAGED=__GONAVI_UPDATE_STAGED__"
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
set PID=__GONAVI_UPDATE_PID__
set /a WAIT_PID_SECONDS=0
call :log updater started
if not exist "%SOURCE%" (
call :log source file not found: %SOURCE%
exit /b 1
)
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
for %%I in ("%TARGET%") do set "TARGET_DIR=%%~dpI"
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
set "SOURCE_EXE="
if /I "%SOURCE_EXT%"==".zip" (
set "EXTRACT_DIR=%STAGED%\_extract"
if exist "%EXTRACT_DIR%" (
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
)
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log expand zip failed: %SOURCE%
exit /b 1
)
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
) else (
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
if not defined SOURCE_EXE (
set "SOURCE_EXE=%%~fF"
)
)
)
if not defined SOURCE_EXE (
call :log no executable found in portable zip: %SOURCE%
exit /b 1
)
) else (
set "SOURCE_EXE=%SOURCE%"
)
:waitloop
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
if %ERRORLEVEL%==0 (
if !WAIT_PID_SECONDS! GEQ 90 (
call :log host process still running after !WAIT_PID_SECONDS! seconds, aborting update
exit /b 1
)
timeout /t 1 /nobreak >nul
set /a WAIT_PID_SECONDS+=1
goto waitloop
)
call :log host process exited
timeout /t 3 /nobreak >nul
call :log cooldown finished, starting file replace
:replace_binary
if /I "%SOURCE_EXE%"=="%TARGET%" (
call :log downloaded executable already at target path, skip replace
goto move_done
)
set /a RETRY=0
:move_retry
call :log attempt !RETRY!: trying rename-then-copy strategy
move /Y "%TARGET%" "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL!==0 (
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
goto move_done
)
call :log copy after rename failed, restoring old file
move /Y "%TARGET_OLD%" "%TARGET%" >> "%LOG_FILE%" 2>&1
)
call :log rename strategy failed, trying direct move
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL%==0 goto move_done
set /a RETRY+=1
if !RETRY! LSS 15 (
set /a WAIT=1
if !RETRY! GEQ 3 set /a WAIT=2
if !RETRY! GEQ 6 set /a WAIT=3
if !RETRY! GEQ 9 set /a WAIT=5
call :log waiting !WAIT! seconds before retry
timeout /t !WAIT! /nobreak >nul
goto move_retry
)
call :log replace failed after retries (portable mode, no elevation): check directory write permission or file lock
exit /b 1
:move_done
del /F /Q "%TARGET_OLD%" >> "%LOG_FILE%" 2>&1
if /I not "%CURRENT_TARGET%"=="%TARGET%" (
if exist "%CURRENT_TARGET%" del /F /Q "%CURRENT_TARGET%" >> "%LOG_FILE%" 2>&1
)
if exist "%SOURCE%" del /F /Q "%SOURCE%" >> "%LOG_FILE%" 2>&1
start "" /D "%TARGET_DIR%" "%TARGET%" >> "%LOG_FILE%" 2>&1
if %ERRORLEVEL% NEQ 0 (
call :log cmd start failed, trying powershell Start-Process
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%' -WorkingDirectory '%TARGET_DIR%'" >> "%LOG_FILE%" 2>&1
if !ERRORLEVEL! NEQ 0 (
call :log relaunch failed
exit /b 1
)
)
call :log update finished
start "" /MIN cmd.exe /D /C "timeout /t 2 /nobreak >nul & del /F /Q ""%LOG_FILE%"" >nul 2>&1 & rmdir /S /Q ""%STAGED%"" >nul 2>&1"
exit /b 0
:log
echo [%date% %time%] %*>>"%LOG_FILE%"
exit /b 0
`
return strings.NewReplacer(
"__GONAVI_UPDATE_SOURCE__", source,
"__GONAVI_UPDATE_TARGET__", target,
"__GONAVI_CURRENT_TARGET__", currentTarget,
"__GONAVI_UPDATE_STAGED__", stagedDir,
"__GONAVI_UPDATE_LOG__", logPath,
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
).Replace(strings.ReplaceAll(script, "\n", "\r\n"))
}

View File

@@ -0,0 +1,113 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestShouldRemoveWindowsUpdateArtifact(t *testing.T) {
cases := []struct {
name string
isDir bool
want bool
}{
{name: "GoNavi-dev-abc-Windows-Amd64.exe", want: true},
{name: "GoNavi-0.8.4-Windows-Amd64.zip", want: true},
{name: "gonavi-update-windows-123.log", want: true},
{name: ".gonavi-update-windows-dev-dev-abc", isDir: true, want: true},
{name: "GoNavi-dev-abc-MacOS-Arm64.dmg", want: false},
{name: "GoNavi.exe", want: false},
{name: "notes.log", want: false},
}
for _, tc := range cases {
if got := shouldRemoveWindowsUpdateArtifact(tc.name, tc.isDir); got != tc.want {
t.Fatalf("shouldRemoveWindowsUpdateArtifact(%q, %v) = %v, want %v", tc.name, tc.isDir, got, tc.want)
}
}
}
func TestCleanupWindowsUpdateArtifactsKeepsCurrentTargetAndRemovesStalePackages(t *testing.T) {
dir := t.TempDir()
currentTarget := filepath.Join(dir, "GoNavi-dev-current-Windows-Amd64.exe")
currentPackage := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe")
stalePackage := filepath.Join(dir, "GoNavi-dev-old-Windows-Amd64.exe")
staleLog := filepath.Join(dir, "gonavi-update-windows-123.log")
staleStage := filepath.Join(dir, ".gonavi-update-windows-dev-old")
otherFile := filepath.Join(dir, "notes.txt")
for _, path := range []string{currentTarget, currentPackage, stalePackage, staleLog, otherFile} {
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) returned error: %v", path, err)
}
}
if err := os.MkdirAll(staleStage, 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
cleanupWindowsUpdateArtifacts([]string{dir}, map[string]struct{}{
cleanComparablePath(currentTarget): {},
cleanComparablePath(currentPackage): {},
})
for _, path := range []string{currentTarget, currentPackage, otherFile} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %q to remain: %v", path, err)
}
}
for _, path := range []string{stalePackage, staleLog, staleStage} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %q to be removed, stat err=%v", path, err)
}
}
}
func TestPrepareWindowsStagedUpdateAssetMovesPackageIntoStagedDir(t *testing.T) {
dir := t.TempDir()
stagedDir := filepath.Join(dir, ".gonavi-update-windows-dev-new")
source := filepath.Join(dir, "GoNavi-dev-new-Windows-Amd64.exe")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
prepared, err := prepareWindowsStagedUpdateAsset(source, stagedDir)
if err != nil {
t.Fatalf("prepareWindowsStagedUpdateAsset returned error: %v", err)
}
want := filepath.Join(stagedDir, filepath.Base(source))
if prepared != want {
t.Fatalf("expected prepared path %q, got %q", want, prepared)
}
if _, err := os.Stat(source); !os.IsNotExist(err) {
t.Fatalf("expected source to be moved away, stat err=%v", err)
}
if data, err := os.ReadFile(prepared); err != nil || string(data) != "payload" {
t.Fatalf("expected payload in staged file, data=%q err=%v", string(data), err)
}
}
func TestBuildWindowsScriptWithCleanupRemovesLogAndStagedDirectoryAfterSuccess(t *testing.T) {
script := buildWindowsScriptWithCleanup(
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\GoNavi-dev-new-Windows-Amd64.exe`,
`C:\Users\tester\Desktop\GoNavi-dev-current-Windows-Amd64.exe`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new`,
`C:\Users\tester\AppData\Local\Temp\gonavi-updates\.gonavi-update-windows-dev-new\gonavi-update-windows-123.log`,
12345,
)
mustContain := []string{
`if exist "%SOURCE%" del /F /Q "%SOURCE%"`,
`del /F /Q ""%LOG_FILE%""`,
`rmdir /S /Q ""%STAGED%""`,
}
for _, token := range mustContain {
if !strings.Contains(script, token) {
t.Fatalf("expected script to contain %q\n%s", token, script)
}
}
if strings.Contains(script, `rmdir /S /Q "%STAGED%" >> "%LOG_FILE%"`) {
t.Fatalf("script should not synchronously remove staged dir while logging to it\n%s", script)
}
}

View File

@@ -19,6 +19,12 @@ func configureSQLConnectionPool(db *sql.DB, dbType string) {
switch strings.ToLower(strings.TrimSpace(dbType)) {
case "sqlite", "duckdb":
return
case "oracle", "oceanbase":
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
db.SetMaxIdleConns(1)
db.SetConnMaxIdleTime(defaultSQLConnMaxIdleTime)
db.SetConnMaxLifetime(defaultSQLConnMaxLifetime)
return
}
db.SetMaxOpenConns(defaultSQLMaxOpenConns)
db.SetMaxIdleConns(0)