; ============================================================================ ; ykxmz 教室电脑手表遥控 · 被控端安装包 ; ; 用 Inno Setup 6 编译: ; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\ykxmz.iss ; ; ── 设计说明 ── ; ; 尽量用 Inno 自带的机制,不手搓: ; * 许可协议页 -> LicenseFile(三份文档合并成一份,点"我接受"才能继续) ; * 开机自启 -> [Tasks](标准勾选框) ; * 装完启动 -> [Run] postinstall(标准勾选框) ; * 安装目录 -> DisableDirPage=no(让用户自己选,默认 Program Files) ; * 卸载 -> [UninstallRun] 调被控端自己的 --uninstall ; 只有"填 6 位配对码"这一页 Inno 没有现成的,用标准的 ; CreateInputQueryPage —— 那是 Inno 的官方 API,不算自己造轮子。 ; ; ── 用户确定的设置 ── ; 安装位置 默认 C:\Program Files\ykxmz,安装时可改 ; 数据目录 能写 exe 旁就写,不能就转 ProgramData(程序内部处理) ; 三份文档 放安装包里,统一接受才能下一步 ; 配对码 安装向导里填 6 位 ; 快捷方式 不建开始菜单、不建桌面 ; 开机自启 默认勾上 ; 装完启动 立即静默启动 ; 卸载 一并删掉,什么都不留 ; ============================================================================ #define MyAppName "ykxmz" #define MyAppVersion "1.7.1" #define MyAppPublisher "小麦子" #define MyAppExeName "ykxmz.exe" [Setup] AppId={{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30} AppName={#MyAppName} AppVersion={#MyAppVersion} AppVerName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} VersionInfoVersion={#MyAppVersion} ; 默认装到 Program Files\ykxmz,但安装向导里有一页让用户自己改 DefaultDirName={autopf}\ykxmz DisableDirPage=no ; 三份文档合并成一份许可页:看完点「我接受」才能继续 LicenseFile=license-combined.txt ; 不建开始菜单组(用户明确不要) DisableProgramGroupPage=yes AllowNoIcons=yes ; 装到 Program Files 和改防火墙都要管理员 PrivilegesRequired=admin ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible OutputDir=..\dist OutputBaseFilename=ykxmz-setup-{#MyAppVersion} SetupIconFile=..\windows\app.ico UninstallDisplayIcon={app}\{#MyAppExeName} UninstallDisplayName={#MyAppName} Compression=lzma2/max SolidCompression=yes WizardStyle=modern ShowLanguageDialog=no DisableWelcomePage=no [Languages] Name: "chinese"; MessagesFile: "compiler:Default.isl" [Tasks] ; 用户要的:默认勾上开机自启(取消勾选也允许) Name: "autostart"; Description: "开机自动启动(推荐给教室电脑)"; \ GroupDescription: "启动选项:"; Flags: checkedonce [Files] ; 被控端整个目录(ykxmz.exe + _internal) Source: "..\dist_exe\ykxmz\*"; DestDir: "{app}"; \ Flags: recursesubdirs createallsubdirs ignoreversion ; 三份文档也放一份到安装目录,装完还能翻 Source: "..\docs\LICENSE.txt"; DestDir: "{app}\docs"; Flags: ignoreversion Source: "..\docs\BEFORE-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion Source: "..\docs\AFTER-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion [Dirs] ; ⚠ 这一节很关键 —— 不配的话装完一运行就报 WinError 5 拒绝访问。 ; ; 被控端的配置存在 %ProgramData%\ykxmz\data 下(exe 旁边写不进去时的回退)。 ; 安装包是管理员跑的,它建的目录默认只有管理员能改; ; 之后普通用户(老师的账号)跑被控端时: ; 能新建文件,但覆盖不了管理员建的那份 settings.json ; 结果就是"装完打开报权限错误"。 ; ; Permissions: users-modify 把所有用户的写权限打开,才是真的能用。 ; 注意末尾不带反斜杠,否则 Inno 会给它单独建一层同名子目录。 Name: "{commonappdata}\ykxmz"; Permissions: users-modify Name: "{commonappdata}\ykxmz\data"; Permissions: users-modify [InstallDelete] ; 早期版本把配置写在程序目录下的 data\ 里。 ; 现在数据统一放 ProgramData(见 config.py 的 data_dir), ; 这里把旧的那份清掉 —— 留着的话,两份配置并存, ; 以后排查问题会被误导("我明明改了配对码怎么没变")。 Type: filesandordirs; Name: "{app}\data" [Run] ; 装完立即静默启动(标准 postinstall 勾选框,默认勾上) Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ Description: "立即启动(后台运行,不显示窗口)"; \ Flags: nowait postinstall skipifsilent ; 让用户看一眼装完须知 Filename: "{app}\docs\AFTER-INSTALL.txt"; \ Description: "打开「安装后须知」"; \ Flags: postinstall shellexec unchecked skipifsilent [UninstallRun] ; 卸载前先让被控端自己收尾:停进程、删开机自启、清数据目录。 ; 这样"卸载后什么都不留"才是真的干净。 Filename: "{app}\{#MyAppExeName}"; Parameters: "--uninstall"; \ Flags: runhidden waituntilterminated; RunOnceId: "StopAgent" [UninstallDelete] ; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留 Type: filesandordirs; Name: "{app}\data" Type: filesandordirs; Name: "{app}\docs" Type: filesandordirs; Name: "{app}\_internal" [Code] var CodePage: TInputQueryWizardPage; ForcedCode: String; { 把字符串里的非数字滤掉。 } function OnlyDigits(const raw: String): String; var i: Integer; ch: Char; begin Result := ''; for i := 1 to Length(raw) do begin ch := raw[i]; if (ch >= '0') and (ch <= '9') then Result := Result + ch; end; end; { 配对码那一页。Inno 没有现成的输入页,用官方的 CreateInputQueryPage。 } procedure InitializeWizard(); begin { 静默部署可以带 /CODE=123456 直接把码传进来(电教批量装用这个)。 注意:Inno 的 [Code] 段连**注释里的花括号**都会当常量去解析, 所以这里不能直接写 param 那种写法,否则编译器报语法错误。 } ForcedCode := OnlyDigits(ExpandConstant('{param:CODE|}')); CodePage := CreateInputQueryPage(wpSelectTasks, '配对码', '给这台电脑定一个 6 位配对码', '手表上要输这一串数字才能连上这台电脑。自己定个好记的,' + '比如 789113。装完之后也可以在被控端窗口里改。'); CodePage.Add('6 位配对码:', False); if ForcedCode <> '' then CodePage.Values[0] := ForcedCode; end; { 校验:必须是 6 位数字。 注意 —— **静默模式下这一页不会显示**,值是空的, 这时候绝不能拦(否则 /VERYSILENT 安装必然失败,我第一版就栽在这)。 } function NextButtonClick(CurPageID: Integer): Boolean; var raw: String; digits: String; begin Result := True; if CurPageID <> CodePage.ID then Exit; if WizardSilent then Exit; raw := CodePage.Values[0]; digits := OnlyDigits(raw); if (Length(digits) <> 6) and (raw <> '') then begin MsgBox('配对码要 6 位数字。' + #13#10 + #13#10 + '你现在填的是:' + raw + #13#10 + '(过滤掉非数字后剩 ' + IntToStr(Length(digits)) + ' 位)' + #13#10 + #13#10 + '重新填一下,或者直接点「取消」退出安装。', mbError, MB_OK); Result := False; Exit; end; { 规范化,后面 [Run] 阶段用得到 } CodePage.Values[0] := digits; end; { 最终用的配对码:命令行 /CODE 优先,其次是向导里填的。 两个都没有就返回空串 —— 那就跳过 --code,让被控端自己生成一个。 } function EffectiveCode(): String; begin Result := ForcedCode; if Result = '' then Result := OnlyDigits(CodePage.Values[0]); if Length(Result) <> 6 then Result := ''; end; { 把向导里收集的东西交给被控端。 注意这里**不能带 --silent** —— 那是常驻守护进程,永远不返回, 用 ewWaitUntilTerminated 等它会把安装程序挂死(第一版就栽在这)。 写配置和启动服务分成两次调用: --code / --autostart 一次性命令,秒退 --silent 常驻,交给 [Run] 段用 nowait 启动 } procedure CurStepChanged(CurStep: TSetupStep); var ResultCode: Integer; Params: String; Code: String; begin if CurStep <> ssPostInstall then Exit; Code := EffectiveCode(); if Code <> '' then begin Params := '--code ' + Code; if not Exec(ExpandConstant('{app}\{#MyAppExeName}'), Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin if not WizardSilent then MsgBox('配对码写入失败(错误码 ' + IntToStr(ResultCode) + ')。' + #13#10 + '装完之后双击程序图标,重新设一次也行。', mbInformation, MB_OK); end; end; { 开机自启:单独调一次,也是一次性命令 } if WizardIsTaskSelected('autostart') then Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--autostart on', ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); end; { 卸载前先停掉正在跑的进程,不然文件删不干净。 } function InitializeUninstall(): Boolean; var ResultCode: Integer; begin Result := True; Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall', ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); end;