From 3c076785f67f3d57510e7f9d28f6d1cd6c394e4e Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 7 Jul 2026 19:29:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Windows=20=E5=AE=89=E8=A3=85=E5=99=A8?= =?UTF-8?q?=20+=20=E4=BF=AE=E5=A4=8D=20daemon=20=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Inno Setup 安装器脚本 (installer/lmvpn.iss),支持自定义安装路径、 桌面快捷方式、安装前杀死旧进程,打包 wintun.dll - Makefile 新增 installer-windows 目标,支持 ISCC 和 Wine 两种编译方式 - Windows IPC 从 AF_UNIX 改为 TCP (127.0.0.1:18923),解决提权 daemon 与 非提权 GUI 之间的完整性级别限制导致 daemon did not become reachable - 修复 elevation_windows.go 中 ShellExecute 参数路径含空格时被截断的问题 (C:\Program Files\LMVPN\lmvpnd.exe → 只取到 C:\Program) - ensureDaemon 重试次数 40→60,添加连接失败日志便于诊断 - 新增 IPCNetwork()/IPCAddress() 跨平台函数,统一 IPC 传输层抽象 --- Makefile | 18 +- installer/ChineseSimplified.isl | 417 +++++++++++++++++++++++++++++++ installer/lmvpn.iss | 145 +++++++++++ internal/daemon/daemon.go | 4 +- internal/ipc/ipc.go | 23 +- internal/paths/paths_darwin.go | 8 +- internal/paths/paths_other.go | 8 +- internal/paths/paths_windows.go | 18 +- internal/ui/daemon.go | 7 +- internal/ui/elevation_windows.go | 2 +- 10 files changed, 626 insertions(+), 24 deletions(-) create mode 100644 installer/ChineseSimplified.isl create mode 100644 installer/lmvpn.iss diff --git a/Makefile b/Makefile index ba50077..c0285da 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) VERSION = 0.3.6-$(GIT_HASH) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) -.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows +.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows ## all: build the .app bundle (default) all: app @@ -96,3 +96,19 @@ build-windows: icon-windows CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \ $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(DAEMON_BIN).exe ./cmd/lmvpnd @echo "Built $(BUILD_DIR)/$(GUI_BIN).exe and $(BUILD_DIR)/$(DAEMON_BIN).exe" + +## installer-windows: build exes and compile Inno Setup installer (.exe) +## Requires ISCC on PATH (Windows) or Wine + Inno Setup 6 (macOS/Linux). +installer-windows: build-windows + @echo "Compiling installer with version $(VERSION)..." + @if command -v ISCC >/dev/null 2>&1; then \ + ISCC /DAppVersion=$(VERSION) installer/lmvpn.iss; \ + elif command -v wine >/dev/null 2>&1; then \ + wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=$(VERSION) installer/lmvpn.iss; \ + else \ + echo "ERROR: ISCC not found. Install Inno Setup 6 on Windows,"; \ + echo " or install Wine + Inno Setup 6 on macOS/Linux."; \ + echo " Download: https://jrsoftware.org/isdl.php"; \ + exit 1; \ + fi + @echo "Installer: $(BUILD_DIR)/LMVPN-Setup-$(VERSION).exe" diff --git a/installer/ChineseSimplified.isl b/installer/ChineseSimplified.isl new file mode 100644 index 0000000..0fced97 --- /dev/null +++ b/installer/ChineseSimplified.isl @@ -0,0 +1,417 @@ +; *** Inno Setup version 6.5.0+ Chinese Simplified messages *** +; +; To download user-contributed translations of this file, go to: +; https://jrsoftware.org/files/istrans/ +; +; Note: When translating this text, do not add periods (.) to the end of +; messages that didn't have them already, because on those messages Inno +; Setup adds the periods automatically (appending a period would result in +; two periods being displayed). +; +; Maintainer: Zhenghan Yang (Kira) +; Email: 847320916@QQ.com +; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation +; Encoding: UTF-8 +; Translation based on network resource +; + +[LangOptions] +; The following three entries are very important. Be sure to read and +; understand the '[LangOptions] section' topic in the help file. +LanguageName=简体中文 +; About LanguageID, to reference link: +; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c +LanguageID=$0804 +; LanguageCodePage should always be set if possible, even if this file is Unicode +; For English it's set to zero anyway because English only uses ASCII characters +LanguageCodePage=936 +; If the language you are translating to requires special font faces or +; sizes, uncomment any of the following entries and change them accordingly. +;DialogFontName= +;DialogFontSize=9 +;DialogFontBaseScaleWidth=7 +;DialogFontBaseScaleHeight=15 +;WelcomeFontName=Segoe UI +;WelcomeFontSize=14 + +[Messages] + +; *** Application titles +SetupAppTitle=安装 +SetupWindowTitle=安装 - %1 +UninstallAppTitle=卸载 +UninstallAppFullTitle=%1 卸载 + +; *** Misc. common +InformationTitle=信息 +ConfirmTitle=确认 +ErrorTitle=错误 + +; *** SetupLdr messages +SetupLdrStartupMessage=现在将安装 %1。您想要继续吗? +LdrCannotCreateTemp=无法创建临时文件。安装程序已中止 +LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止 +HelpTextNote= + +; *** Startup error messages +LastErrorMessage=%1。%n%n错误 %2: %3 +SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。 +SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。 +SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。 +InvalidParameter=无效的命令行参数:%n%n%1 +SetupAlreadyRunning=安装程序已在运行。 +WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。 +WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。 +NotOnThisPlatform=此程序不能在 %1 上运行。 +OnlyOnThisPlatform=此程序只能在 %1 上运行。 +OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1 +WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。 +WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。 +AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。 +PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。 +SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 +UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 + +; *** Startup questions +PrivilegesRequiredOverrideTitle=选择安装程序安装模式 +PrivilegesRequiredOverrideInstruction=选择安装模式 +PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。 +PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。 +PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A) +PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐) +PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M) +PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐) + +; *** Misc. errors +ErrorCreatingDir=安装程序无法创建目录“%1” +ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件 + +; *** Setup common messages +ExitSetupTitle=退出安装程序 +ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗? +AboutSetupMenuItem=关于安装程序(&A)... +AboutSetupTitle=关于安装程序 +AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4 +AboutSetupNote= +TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation + +; *** Buttons +ButtonBack=< 上一步(&B) +ButtonNext=下一步(&N) > +ButtonInstall=安装(&I) +ButtonOK=确定 +ButtonCancel=取消 +ButtonYes=是(&Y) +ButtonYesToAll=全是(&A) +ButtonNo=否(&N) +ButtonNoToAll=全否(&O) +ButtonFinish=完成(&F) +ButtonBrowse=浏览(&B)... +ButtonWizardBrowse=浏览(&R)... +ButtonNewFolder=新建文件夹(&M) + +; *** "Select Language" dialog messages +SelectLanguageTitle=选择安装语言 +SelectLanguageLabel=选择安装时使用的语言。 + +; *** Common wizard text +ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。 +BeveledLabel= +BrowseDialogTitle=浏览文件夹 +BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。 +NewFolderName=新建文件夹 + +; *** "Welcome" wizard page +WelcomeLabel1=欢迎使用 [name] 安装向导 +WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。 + +; *** "Password" wizard page +WizardPassword=密码 +PasswordLabel1=此安装程序需要密码验证。 +PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。 +PasswordEditLabel=密码(&P): +IncorrectPassword=您输入的密码不正确,请重新输入。 + +; *** "License Agreement" wizard page +WizardLicense=许可协议 +LicenseLabel=请在继续安装前阅读以下重要信息。 +LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。 +LicenseAccepted=我同意此协议(&A) +LicenseNotAccepted=我不同意此协议(&D) + +; *** "Information" wizard pages +WizardInfoBefore=信息 +InfoBeforeLabel=请在继续安装前阅读以下重要信息。 +InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。 +WizardInfoAfter=信息 +InfoAfterLabel=请在继续安装前阅读以下重要信息。 +InfoAfterClickLabel=准备好继续安装后,点击“下一步”。 + +; *** "User Information" wizard page +WizardUserInfo=用户信息 +UserInfoDesc=请输入您的信息。 +UserInfoName=用户名(&U): +UserInfoOrg=组织(&O): +UserInfoSerial=序列号(&S): +UserInfoNameRequired=请输入用户名。 + +; *** "Select Destination Location" wizard page +WizardSelectDir=选择目标位置 +SelectDirDesc=您想将 [name] 安装在哪里? +SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。 +SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。 +DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。 +CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。 +CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。 +InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share +InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。 +DiskSpaceWarningTitle=磁盘空间不足 +DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗? +DirNameTooLong=文件夹名称或路径太长。 +InvalidDirName=文件夹名称无效。 +BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1 +DirExistsTitle=文件夹已存在 +DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗? +DirDoesntExistTitle=文件夹不存在 +DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗? + +; *** "Select Components" wizard page +WizardSelectComponents=选择组件 +SelectComponentsDesc=您想安装哪些程序组件? +SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。 +FullInstallation=完全安装 +; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) +CompactInstallation=简洁安装 +CustomInstallation=自定义安装 +NoUninstallWarningTitle=组件已存在 +NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗? +ComponentSize1=%1 KB +ComponentSize2=%1 MB +ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。 +ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。 + +; *** "Select Additional Tasks" wizard page +WizardSelectTasks=选择附加任务 +SelectTasksDesc=您想要安装程序执行哪些附加任务? +SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。 + +; *** "Select Start Menu Folder" wizard page +WizardSelectProgramGroup=选择开始菜单文件夹 +SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式? +SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。 +SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +MustEnterGroupName=您必须输入一个文件夹名称。 +GroupNameTooLong=文件夹名称或路径太长。 +InvalidGroupName=文件夹名称无效。 +BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1 +NoProgramGroupCheck2=不创建开始菜单文件夹(&D) + +; *** "Ready to Install" wizard page +WizardReady=准备安装 +ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。 +ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。 +ReadyLabel2b=点击“安装”继续此安装程序。 +ReadyMemoUserInfo=用户信息: +ReadyMemoDir=目标位置: +ReadyMemoType=安装类型: +ReadyMemoComponents=已选择组件: +ReadyMemoGroup=开始菜单文件夹: +ReadyMemoTasks=附加任务: + +; *** TDownloadWizardPage wizard page and DownloadTemporaryFile +DownloadingLabel2=正在下载文件... +ButtonStopDownload=停止下载(&S) +StopDownload=您确定要停止下载吗? +ErrorDownloadAborted=下载已中止 +ErrorDownloadFailed=下载失败:%1 %2 +ErrorDownloadSizeFailed=获取大小失败:%1 %2 +ErrorProgress=无效的进度:%1 / %2 +ErrorFileSize=文件大小错误:预期 %1,实际 %2 + +; *** TExtractionWizardPage wizard page and ExtractArchive +ExtractingLabel=正在提取文件... +ButtonStopExtraction=停止提取(&S) +StopExtraction=您确定要停止提取吗? +ErrorExtractionAborted=提取已中止 +ErrorExtractionFailed=提取失败:%1 + +; *** Archive extraction failure details +ArchiveIncorrectPassword=密码不正确 +ArchiveIsCorrupted=压缩包已损坏 +ArchiveUnsupportedFormat=不支持的压缩包格式 + +; *** "Preparing to Install" wizard page +WizardPreparing=正在准备安装 +PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。 +PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。 +CannotContinue=安装程序不能继续。请点击“取消”退出。 +ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。 +ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。 +CloseApplications=自动关闭应用程序(&A) +DontCloseApplications=不要关闭应用程序(&D) +ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。 +PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗? + +; *** "Installing" wizard page +WizardInstalling=正在安装 +InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。 + +; *** "Setup Completed" wizard page +FinishedHeadingLabel=完成 [name] 安装向导 +FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。 +FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。 +ClickFinish=点击“完成”退出安装程序。 +FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗? +FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗? +ShowReadmeCheck=是,我想查阅自述文件 +YesRadio=是,立即重启计算机(&Y) +NoRadio=否,稍后重启计算机(&N) +; used for example as 'Run MyProg.exe' +RunEntryExec=运行 %1 +; used for example as 'View Readme.txt' +RunEntryShellExec=查阅 %1 + +; *** "Setup Needs the Next Disk" stuff +ChangeDiskTitle=安装程序需要下一张磁盘 +SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。 +PathLabel=路径(&P): +FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。 +SelectDirectoryLabel=请指定下一张磁盘的位置。 + +; *** Installation phase messages +SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。 +AbortRetryIgnoreSelectAction=选择操作 +AbortRetryIgnoreRetry=重试(&T) +AbortRetryIgnoreIgnore=忽略错误并继续(&I) +AbortRetryIgnoreCancel=取消安装 +RetryCancelSelectAction=选择操作 +RetryCancelRetry=重试(&T) +RetryCancelCancel=取消 + +; *** Installation status messages +StatusClosingApplications=正在关闭应用程序... +StatusCreateDirs=正在创建目录... +StatusExtractFiles=正在提取文件... +StatusDownloadFiles=正在下载文件... +StatusCreateIcons=正在创建快捷方式... +StatusCreateIniEntries=正在创建 INI 条目... +StatusCreateRegistryEntries=正在创建注册表条目... +StatusRegisterFiles=正在注册文件... +StatusSavingUninstall=正在保存卸载信息... +StatusRunProgram=正在完成安装... +StatusRestartingApplications=正在重启应用程序... +StatusRollback=正在撤销更改... + +; *** Misc. errors +ErrorInternal2=内部错误:%1 +ErrorFunctionFailedNoCode=%1 失败 +ErrorFunctionFailed=%1 失败;错误代码 %2 +ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3 +ErrorExecutingProgram=无法执行文件:%n%1 + +; *** Registry errors +ErrorRegOpenKey=打开注册表项时出错:%n%1\%2 +ErrorRegCreateKey=创建注册表项时出错:%n%1\%2 +ErrorRegWriteKey=写入注册表项时出错:%n%1\%2 + +; *** INI errors +ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。 + +; *** File copying errors +FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐) +FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐) +SourceIsCorrupted=源文件已损坏 +SourceDoesntExist=源文件“%1”不存在 +SourceVerificationFailed=源文件验证失败:%1 +VerificationSignatureDoesntExist=签名文件“%1”不存在 +VerificationSignatureInvalid=签名文件“%1”无效 +VerificationKeyNotFound=签名文件“%1”使用了未知的密钥 +VerificationFileNameIncorrect=文件名不正确 +VerificationFileTagIncorrect=文件标签不正确 +VerificationFileSizeIncorrect=文件大小不正确 +VerificationFileHashIncorrect=文件哈希值不正确 +ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。 +ExistingFileReadOnlyRetry=移除只读属性并重试(&R) +ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K) +ErrorReadingExistingDest=尝试读取已存在的文件时出错: +FileExistsSelectAction=选择操作 +FileExists2=文件已经存在。 +FileExistsOverwriteExisting=覆盖已存在的文件(&O) +FileExistsKeepExisting=保留已存在的文件(&K) +FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D) +ExistingFileNewerSelectAction=选择操作 +ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。 +ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O) +ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐) +ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D) +ErrorChangingAttr=尝试更改下列已存在的文件属性时出错: +ErrorCreatingTemp=尝试在目标目录创建文件时出错: +ErrorReadingSource=尝试读取下列源文件时出错: +ErrorCopying=尝试复制下列文件时出错: +ErrorDownloading=尝试下载文件时出错: +ErrorExtracting=尝试提取压缩包时出错: +ErrorReplacingExistingFile=尝试替换已存在的文件时出错: +ErrorRestartReplace=重启并替换失败: +ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错: +ErrorRegisterServer=无法注册 DLL/OCX:%1 +ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1 +ErrorRegisterTypeLib=无法注册类型库:%1 + +; *** Uninstall display name markings +; used for example as 'My Program (32-bit)' +UninstallDisplayNameMark=%1 (%2) +; used for example as 'My Program (32-bit, All users)' +UninstallDisplayNameMarks=%1 (%2, %3) +UninstallDisplayNameMark32Bit=32 位 +UninstallDisplayNameMark64Bit=64 位 +UninstallDisplayNameMarkAllUsers=所有用户 +UninstallDisplayNameMarkCurrentUser=当前用户 + +; *** Post-installation errors +ErrorOpeningReadme=尝试打开自述文件时出错。 +ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。 + +; *** Uninstaller messages +UninstallNotFound=文件“%1”不存在。无法卸载。 +UninstallOpenError=文件“%1”不能被打开。无法卸载 +UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载 +UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1) +ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗? +UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。 +OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。 +UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。 +UninstalledAll=已顺利从您的计算机中移除 %1。 +UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。 +UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗? +UninstallDataCorrupted=文件“%1”已损坏。无法卸载 + +; *** Uninstallation phase messages +ConfirmDeleteSharedFileTitle=删除共享文件? +ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。 +SharedFileNameLabel=文件名: +SharedFileLocationLabel=位置: +WizardUninstalling=卸载状态 +StatusUninstalling=正在卸载 %1... + +; *** Shutdown block reasons +ShutdownBlockReasonInstallingApp=正在安装 %1。 +ShutdownBlockReasonUninstallingApp=正在卸载 %1。 + +; The custom messages below aren't used by Setup itself, but if you make +; use of them in your scripts, you'll want to translate them. + +[CustomMessages] + +NameAndVersion=%1 版本 %2 +AdditionalIcons=附加快捷方式: +CreateDesktopIcon=创建桌面快捷方式(&D) +CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q) +ProgramOnTheWeb=%1 网站 +UninstallProgram=卸载 %1 +LaunchProgram=运行 %1 +AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A) +AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联... +AutoStartProgramGroupDescription=启动: +AutoStartProgram=自动启动 %1 +AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗? diff --git a/installer/lmvpn.iss b/installer/lmvpn.iss new file mode 100644 index 0000000..4f11ec7 --- /dev/null +++ b/installer/lmvpn.iss @@ -0,0 +1,145 @@ +; LMVPN Windows Installer — Inno Setup script +; +; Build: +; ISCC /DAppVersion=0.3.6-abc123 installer/lmvpn.iss +; wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=0.3.6-abc123 installer/lmvpn.iss +; +; The exes (lmvpn.exe, lmvpnd.exe) must exist in ../build/ before compiling. +; Run `make build-windows` first (or `make installer-windows` which does both). + +#ifndef AppVersion + #define AppVersion "dev" +#endif + +#define AppName "LMVPN" +#define AppPublisher "LMVPN" +#define AppExeName "lmvpn.exe" +#define DaemonExeName "lmvpnd.exe" +#define AppURL "https://github.com/lmvpn/lmvpn_client" + +[Setup] +AppId={{8F7B3A2E-1D4C-4B5E-9F6A-2E3C7D8E1F0A} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#AppURL} +AppUpdatesURL={#AppURL} +DefaultDirName={autopf}\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +PrivilegesRequired=admin +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +OutputDir=..\build +OutputBaseFilename=LMVPN-Setup-{#AppVersion} +SetupIconFile=..\resources\icon.ico +UninstallDisplayIcon={app}\{#AppExeName} +UninstallDisplayName={#AppName} + +[Languages] +Name: "chinesesimp"; MessagesFile: "ChineseSimplified.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "创建桌面快捷方式(&D)"; GroupDescription: "附加选项:"; Flags: unchecked + +[Files] +Source: "..\build\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\build\{#DaemonExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\resources\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{group}\卸载 {#AppName}"; Filename: "{uninstallexe}" +Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "立即启动 {#AppName}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +Type: files; Name: "{app}\wintun.dll" + +[Code] + +// Kill running LMVPN processes (GUI + daemon) by image name. +// Returns True if at least one process was found and killed. +function KillProcess(const ExeName: String): Boolean; +var + ResultCode: Integer; +begin + Result := False; + // taskkill /IM /F /T — force-kill process tree + if Exec(ExpandConstant('{cmd}'), '/C taskkill /IM ' + ExeName + ' /F /T', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + // exit code 0 = killed, 128 = not found — both are fine + Result := (ResultCode = 0); + end; +end; + +// PrepareToInstall runs before file extraction. We kill any running +// LMVPN processes here so that the exes can be overwritten cleanly. +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + KillProcess('{#DaemonExeName}'); + KillProcess('{#AppExeName}'); + // Give the OS a moment to release file handles + Sleep(800); + Result := ''; +end; + +// Kill processes before uninstall as well. +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + KillProcess('{#DaemonExeName}'); + KillProcess('{#AppExeName}'); + Sleep(500); + end; +end; + +// Check if a process with the given image name is currently running. +function IsProcessRunning(const ExeName: String): Boolean; +var + ResultCode: Integer; +begin + Result := False; + // tasklist exits 0 if the process is found, 1 if not found + if Exec(ExpandConstant('{cmd}'), '/C tasklist /FI "IMAGENAME eq ' + ExeName + '" /NH /FO CSV | findstr /I "' + ExeName + '"', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Result := (ResultCode = 0); +end; + +// Warn the user if LMVPN is still running and offer to kill it +// before proceeding from the directory selection page. +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if CurPageID = wpSelectDir then + begin + if IsProcessRunning('{#AppExeName}') or IsProcessRunning('{#DaemonExeName}') then + begin + if MsgBox('{#AppName} 正在运行中,需要先关闭才能继续安装。' + #13#10 + #13#10 + '是否立即关闭?', + mbConfirmation, MB_YESNO) = IDYES then + begin + KillProcess('{#DaemonExeName}'); + KillProcess('{#AppExeName}'); + Sleep(800); + // Verify they're actually dead + if IsProcessRunning('{#AppExeName}') or IsProcessRunning('{#DaemonExeName}') then + begin + MsgBox('无法关闭 {#AppName} 进程,请手动结束任务后重试。', mbError, MB_OK); + Result := False; + end; + end + else + Result := False; + end; + end; +end; diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 39c5664..e6e0e2d 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -60,8 +60,8 @@ func Run(userHome string, uid, gid int) error { // Chown the IPC socket so the non-root GUI process can connect. // This is the critical fix: without it, the socket is owned by // root:wheel with mode 0660, and the user cannot dial it. - chownToUser(paths.IPCSocketPath(), uid, gid) - log.L().Info("daemon listening", "socket", paths.IPCSocketPath()) + chownToUser(paths.IPCAddress(), uid, gid) + log.L().Info("daemon listening", "socket", paths.IPCAddress()) d := &daemon{server: server} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index bc98a4d..b3b5499 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -100,15 +100,26 @@ type Server struct { } // NewServer creates (but does not start) the IPC server. It removes -// any stale socket file first. +// any stale socket file first (unix only). func NewServer() (*Server, error) { - _ = os.Remove(paths.IPCSocketPath()) - l, err := net.Listen("unix", paths.IPCSocketPath()) + netType := paths.IPCNetwork() + addr := paths.IPCAddress() + + // Clean up stale unix socket file (not needed for TCP). + if netType == "unix" { + _ = os.Remove(addr) + } + + l, err := net.Listen(netType, addr) if err != nil { return nil, fmt.Errorf("listen ipc: %w", err) } - // Mode 0660 so group members (admin) can connect. - _ = os.Chmod(paths.IPCSocketPath(), 0o660) + + // Set socket permissions so group members can connect (unix only). + if netType == "unix" { + _ = os.Chmod(addr, 0o660) + } + return &Server{listener: l, clients: make(map[net.Conn]bool)}, nil } @@ -178,7 +189,7 @@ type Client struct { // Dial connects to the daemon. func Dial() (*Client, error) { - conn, err := net.Dial("unix", paths.IPCSocketPath()) + conn, err := net.Dial(paths.IPCNetwork(), paths.IPCAddress()) if err != nil { return nil, fmt.Errorf("dial daemon: %w", err) } diff --git a/internal/paths/paths_darwin.go b/internal/paths/paths_darwin.go index 1f3220b..6cd2055 100644 --- a/internal/paths/paths_darwin.go +++ b/internal/paths/paths_darwin.go @@ -7,9 +7,11 @@ import ( "path/filepath" ) -// IPCSocketPath returns the path to the unix domain socket used for -// GUI <-> daemon communication. -func IPCSocketPath() string { return "/tmp/lmvpn.sock" } +// IPCNetwork returns the transport network for GUI <-> daemon IPC. +func IPCNetwork() string { return "unix" } + +// IPCAddress returns the listen/dial address for GUI <-> daemon IPC. +func IPCAddress() string { return "/tmp/lmvpn.sock" } func init() { home, _ := os.UserHomeDir() diff --git a/internal/paths/paths_other.go b/internal/paths/paths_other.go index 5fff550..735fa35 100644 --- a/internal/paths/paths_other.go +++ b/internal/paths/paths_other.go @@ -7,9 +7,11 @@ import ( "path/filepath" ) -// IPCSocketPath returns the path to the unix domain socket used for -// GUI <-> daemon communication. -func IPCSocketPath() string { return "/tmp/lmvpn.sock" } +// IPCNetwork returns the transport network for GUI <-> daemon IPC. +func IPCNetwork() string { return "unix" } + +// IPCAddress returns the listen/dial address for GUI <-> daemon IPC. +func IPCAddress() string { return "/tmp/lmvpn.sock" } func init() { home, _ := os.UserHomeDir() diff --git a/internal/paths/paths_windows.go b/internal/paths/paths_windows.go index 9888823..2a8eeea 100644 --- a/internal/paths/paths_windows.go +++ b/internal/paths/paths_windows.go @@ -7,12 +7,18 @@ import ( "path/filepath" ) -// IPCSocketPath returns the path to the unix domain socket used for -// GUI <-> daemon communication. On Windows 10 1803+ AF_UNIX is -// supported; the socket is placed in the user's temp directory. -func IPCSocketPath() string { - return filepath.Join(os.TempDir(), "lmvpn.sock") -} +// IPCNetwork returns the transport network for GUI <-> daemon IPC. +// Windows uses TCP because AF_UNIX sockets enforce mandatory +// integrity-level checks: a socket created by the elevated daemon +// (High Integrity) cannot be connected to by the non-elevated GUI +// (Medium Integrity). TCP on 127.0.0.1 has no such restriction. +func IPCNetwork() string { return "tcp" } + +// IPCAddress returns the listen/dial address for GUI <-> daemon IPC. +// On Windows this is a TCP address on localhost. +const ipcPort = "18923" + +func IPCAddress() string { return "127.0.0.1:" + ipcPort } func init() { home, _ := os.UserHomeDir() diff --git a/internal/ui/daemon.go b/internal/ui/daemon.go index fca9fa4..e5bc8d3 100644 --- a/internal/ui/daemon.go +++ b/internal/ui/daemon.go @@ -74,11 +74,14 @@ func ensureDaemon() (*ipc.Client, error) { // Wait for the daemon to become reachable. logFile := paths.DaemonLogFile() - for i := 0; i < 40; i++ { + for i := 0; i < 60; i++ { time.Sleep(250 * time.Millisecond) if c, err := ipc.Dial(); err == nil { - log.L().Info("daemon connected", "socket", paths.IPCSocketPath()) + log.L().Info("daemon connected", "socket", paths.IPCAddress()) return c, nil + } else if i == 0 || i == 10 || i == 30 || i == 59 { + log.L().Warn("daemon not reachable yet, retrying", + "attempt", i+1, "socket", paths.IPCAddress(), "error", err) } } return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile) diff --git a/internal/ui/elevation_windows.go b/internal/ui/elevation_windows.go index 6067eb3..6025a1a 100644 --- a/internal/ui/elevation_windows.go +++ b/internal/ui/elevation_windows.go @@ -15,7 +15,7 @@ const daemonBinaryName = "lmvpnd.exe" // launchElevated launches the daemon-launch subcommand with UAC // elevation via ShellExecute "runas" on Windows. func launchElevated(exe, daemonBin, home string, uid, gid int) error { - args := fmt.Sprintf("daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s", + args := fmt.Sprintf("daemon-launch --user-home \"%s\" --uid %d --gid %d --daemon-bin \"%s\"", home, uid, gid, daemonBin) shell32 := syscall.NewLazyDLL("shell32.dll")