| 1 | /* |
| 2 | * LegacyClonk |
| 3 | * |
| 4 | * Copyright (c) RedWolf Design |
| 5 | * Copyright (c) 2007, matthes |
| 6 | * Copyright (c) 2017-2021, The LegacyClonk Team and contributors |
| 7 | * |
| 8 | * Distributed under the terms of the ISC license; see accompanying file |
| 9 | * "COPYING" for details. |
| 10 | * |
| 11 | * "Clonk" is a registered trademark of Matthes Bender, used with permission. |
| 12 | * See accompanying file "TRADEMARK" for details. |
| 13 | * |
| 14 | * To redistribute this file separately, substitute the full license texts |
| 15 | * for the above references. |
| 16 | */ |
| 17 | |
| 18 | // dialogs for update, and the actual update application code |
| 19 | |
| 20 | #include "C4Include.h" |
| 21 | #include "C4UpdateDlg.h" |
| 22 | #include "C4DownloadDlg.h" |
| 23 | |
| 24 | #include <C4Log.h> |
| 25 | |
| 26 | #include <format> |
| 27 | |
| 28 | #ifdef _WIN32 |
| 29 | #include <shellapi.h> |
| 30 | #else |
| 31 | #include <unistd.h> |
| 32 | #include <fcntl.h> |
| 33 | #include <errno.h> |
| 34 | #include <sys/wait.h> |
| 35 | #include <netinet/in.h> |
| 36 | #include <arpa/inet.h> |
| 37 | #endif |
| 38 | |
| 39 | int C4UpdateDlg::pid; |
| 40 | int C4UpdateDlg::c4group_output[2]; |
| 41 | bool C4UpdateDlg::succeeded; |
| 42 | |
| 43 | // C4UpdateDlg |
| 44 | |
| 45 | C4UpdateDlg::C4UpdateDlg() : C4GUI::InfoDialog(LoadResStr(id: C4ResStrTableKey::IDS_TYPE_UPDATE), 10) |
| 46 | { |
| 47 | // initial text update |
| 48 | UpdateText(); |
| 49 | // assume update running |
| 50 | UpdateRunning = true; |
| 51 | } |
| 52 | |
| 53 | void C4UpdateDlg::UpdateText() |
| 54 | { |
| 55 | if (!UpdateRunning) |
| 56 | return; |
| 57 | #ifdef _WIN32 |
| 58 | AddLine("Win32 is currently not using the C4UpdateDlg!" ); |
| 59 | UpdateRunning = false; |
| 60 | #else |
| 61 | char c4group_output_buf[513]; |
| 62 | ssize_t amount_read; |
| 63 | // transfer output to log window |
| 64 | amount_read = read(fd: c4group_output[0], buf: c4group_output_buf, nbytes: 512); |
| 65 | // error |
| 66 | if (amount_read == -1) |
| 67 | { |
| 68 | if (errno == EAGAIN) |
| 69 | return; |
| 70 | const std::string errorMessage{std::format(fmt: "read error from c4group: {}" , args: strerror(errno))}; |
| 71 | LogNTr(level: spdlog::level::err, message: errorMessage); |
| 72 | AddLine(szText: errorMessage.c_str()); |
| 73 | UpdateRunning = false; |
| 74 | succeeded = false; |
| 75 | } |
| 76 | // EOF: Update done. |
| 77 | else if (amount_read == 0) |
| 78 | { |
| 79 | // Close c4group output |
| 80 | close(fd: c4group_output[0]); |
| 81 | // If c4group did not exit but something else caused EOF, then that's bad. But don't hang. |
| 82 | int child_status = 0; |
| 83 | if (waitpid(pid: pid, stat_loc: &child_status, WNOHANG) == -1) |
| 84 | { |
| 85 | LogNTr(level: spdlog::level::err, fmt: "error in waitpid: {}" , args: strerror(errno)); |
| 86 | AddLine(szText: std::format(fmt: "error in waitpid: {}" , args: strerror(errno)).c_str()); |
| 87 | succeeded = false; |
| 88 | } |
| 89 | // check if c4group failed. |
| 90 | else if (WIFEXITED(child_status) && WEXITSTATUS(child_status)) |
| 91 | { |
| 92 | LogNTr(level: spdlog::level::err, fmt: "c4group returned status {}" , WEXITSTATUS(child_status)); |
| 93 | AddLine(szText: std::format(fmt: "c4group returned status {}" , WEXITSTATUS(child_status)).c_str()); |
| 94 | succeeded = false; |
| 95 | } |
| 96 | else if (WIFSIGNALED(child_status)) |
| 97 | { |
| 98 | LogNTr(level: spdlog::level::err, fmt: "c4group killed with signal {}" , WTERMSIG(child_status)); |
| 99 | AddLine(szText: std::format(fmt: "c4group killed with signal {}" , WTERMSIG(child_status)).c_str()); |
| 100 | succeeded = false; |
| 101 | } |
| 102 | else |
| 103 | { |
| 104 | LogNTr(message: "Done." ); |
| 105 | AddLine(szText: "Done." ); |
| 106 | } |
| 107 | UpdateRunning = false; |
| 108 | } |
| 109 | else |
| 110 | { |
| 111 | c4group_output_buf[amount_read] = 0; |
| 112 | // Fixme: This adds spurious newlines in the middle of the output. |
| 113 | LogNTr(message: c4group_output_buf); |
| 114 | AddLine(szText: c4group_output_buf); |
| 115 | } |
| 116 | #endif |
| 117 | |
| 118 | // Scroll to bottom |
| 119 | if (pTextWin) |
| 120 | { |
| 121 | pTextWin->UpdateHeight(); |
| 122 | pTextWin->ScrollToBottom(); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // static update application function |
| 127 | |
| 128 | bool C4UpdateDlg::DoUpdate(const C4GameVersion &rUpdateVersion, C4GUI::Screen *pScreen) |
| 129 | { |
| 130 | std::string updateFile; |
| 131 | std::string updateURL; |
| 132 | // Double check for valid update |
| 133 | if (!IsValidUpdate(rNewVer: rUpdateVersion)) return false; |
| 134 | // Objects major update: we will update to the first minor of the next major version - we can not skip major versions or jump directly to a higher minor of the next major version. |
| 135 | if (rUpdateVersion.iVer[2] > C4XVER3) |
| 136 | updateFile = std::format(C4CFG_UpdateMajor, args: rUpdateVersion.iVer[0], args: rUpdateVersion.iVer[1], C4XVER3 + 1, args: 0, C4_OS); |
| 137 | // Objects version match: engine update only |
| 138 | else if ((rUpdateVersion.iVer[2] == C4XVER3) && (rUpdateVersion.iVer[3] == C4XVER4)) |
| 139 | updateFile = std::format(C4CFG_UpdateEngine, args: rUpdateVersion.iBuild, C4_OS); |
| 140 | // Objects version mismatch: full objects update |
| 141 | else |
| 142 | updateFile = std::format(C4CFG_UpdateObjects, args: rUpdateVersion.iVer[0], args: rUpdateVersion.iVer[1], args: rUpdateVersion.iVer[2], args: rUpdateVersion.iVer[3], args: rUpdateVersion.iBuild, C4_OS); |
| 143 | // Compose full update URL by using update server address and replacing last path element name with the update file |
| 144 | int iLastElement = SCharLastPos(cTarget: '/', szInStr: Config.Network.UpdateServerAddress); |
| 145 | if (iLastElement > -1) |
| 146 | { |
| 147 | updateURL = Config.Network.UpdateServerAddress; |
| 148 | updateURL.resize(n: iLastElement + 1); |
| 149 | updateURL += updateFile; |
| 150 | } |
| 151 | else |
| 152 | { |
| 153 | // No last slash in update server address? |
| 154 | // Append update file as new segment instead - maybe somebody wants |
| 155 | // to set up their update server this way |
| 156 | updateURL = Config.Network.UpdateServerAddress; |
| 157 | updateURL += '/'; |
| 158 | updateURL += updateFile; |
| 159 | } |
| 160 | // Determine local filename for update group |
| 161 | StdStrBuf strLocalFilename; strLocalFilename.Copy(pnData: GetFilename(path: updateFile.c_str())); |
| 162 | // Download update group to temp path |
| 163 | strLocalFilename.Copy(pnData: Config.AtTempPath(szFilename: strLocalFilename.getData())); |
| 164 | // Download update group |
| 165 | if (!C4DownloadDlg::DownloadFile(szDLType: LoadResStr(id: C4ResStrTableKey::IDS_TYPE_UPDATE), pScreen, szURL: updateURL.c_str(), szSaveAsFilename: strLocalFilename.getData(), szNotFoundMessage: LoadResStr(id: C4ResStrTableKey::IDS_MSG_UPDATENOTAVAILABLE))) |
| 166 | // Download failed (return success, because error message has already been shown) |
| 167 | return true; |
| 168 | // Apply downloaded update |
| 169 | return ApplyUpdate(strUpdateFile: strLocalFilename.getData(), fDeleteUpdate: true, pScreen); |
| 170 | } |
| 171 | |
| 172 | bool C4UpdateDlg::ApplyUpdate(const char *strUpdateFile, bool fDeleteUpdate, C4GUI::Screen *pScreen) |
| 173 | { |
| 174 | // Determine name of update program |
| 175 | StdStrBuf strUpdateProg; strUpdateProg.Copy(C4CFN_UpdateProgram); |
| 176 | // Windows: manually append extension because ExtractEntry() cannot properly glob and Extract() doesn't return failure values |
| 177 | #ifdef _WIN32 |
| 178 | strUpdateProg += ".exe" ; |
| 179 | #endif |
| 180 | // Extract update program (the update should be applied using the new version) |
| 181 | C4Group UpdateGroup, SubGroup; |
| 182 | char strSubGroup[1024 + 1]; |
| 183 | if (!UpdateGroup.Open(szGroupName: strUpdateFile)) return false; |
| 184 | // Look for update program at top level |
| 185 | if (!UpdateGroup.ExtractEntry(szFilename: strUpdateProg.getData(), szExtractTo: strUpdateProg.getData())) |
| 186 | // Not found: look for an engine update pack one level down |
| 187 | if (UpdateGroup.FindEntry(szWildCard: std::format(fmt: "lc_*_{}.c4u" , C4_OS).c_str(), sFileName: strSubGroup)) |
| 188 | // Extract update program from sub group |
| 189 | if (SubGroup.OpenAsChild(pMother: &UpdateGroup, szEntryName: strSubGroup)) |
| 190 | { |
| 191 | SubGroup.ExtractEntry(szFilename: strUpdateProg.getData(), szExtractTo: strUpdateProg.getData()); |
| 192 | SubGroup.Close(); |
| 193 | } |
| 194 | UpdateGroup.Close(); |
| 195 | // Execute update program |
| 196 | Log(id: C4ResStrTableKey::IDS_PRC_LAUNCHINGUPDATE); |
| 197 | succeeded = true; |
| 198 | #ifdef _WIN32 |
| 199 | // Close editor if open |
| 200 | HWND hwnd = FindWindowA(nullptr, C4EDITORCAPTION); |
| 201 | if (hwnd) PostMessage(hwnd, WM_CLOSE, 0, 0); |
| 202 | const std::string updateArgs{std::format("\"{}\" /p -w \"" C4ENGINECAPTION "\" -w \"" C4EDITORCAPTION "\" -w 2000 {}" , strUpdateFile, fDeleteUpdate ? "-yd" : "-y" )}; |
| 203 | const auto iError = ShellExecuteA(nullptr, "runas" , strUpdateProg.getData(), updateArgs.c_str(), Config.General.ExePath, SW_SHOW); |
| 204 | if (reinterpret_cast<intptr_t>(iError) <= 32) return false; |
| 205 | // must quit ourselves for update program to work |
| 206 | if (succeeded) Application.Quit(); |
| 207 | #else |
| 208 | if (pipe(pipedes: c4group_output) == -1) |
| 209 | { |
| 210 | LogNTr(level: spdlog::level::err, message: "Error creating pipe" ); |
| 211 | return false; |
| 212 | } |
| 213 | switch (pid = fork()) |
| 214 | { |
| 215 | // Error |
| 216 | case -1: |
| 217 | LogNTr(level: spdlog::level::err, message: "Error creating update child process." ); |
| 218 | return false; |
| 219 | // Child process |
| 220 | case 0: |
| 221 | // Close unused read end |
| 222 | close(fd: c4group_output[0]); |
| 223 | // redirect stdout and stderr to the parent |
| 224 | dup2(fd: c4group_output[1], STDOUT_FILENO); |
| 225 | dup2(fd: c4group_output[1], STDERR_FILENO); |
| 226 | if (c4group_output[1] != STDOUT_FILENO && c4group_output[1] != STDERR_FILENO) |
| 227 | close(fd: c4group_output[1]); |
| 228 | execl(C4CFN_UpdateProgram, C4CFN_UpdateProgram, "-v" , strUpdateFile, (fDeleteUpdate ? "-yd" : "-y" ), static_cast<char *>(0)); |
| 229 | printf(format: "execl failed: %s\n" , strerror(errno)); |
| 230 | exit(status: 1); |
| 231 | // Parent process |
| 232 | default: |
| 233 | // Close unused write end |
| 234 | close(fd: c4group_output[1]); |
| 235 | // disable blocking |
| 236 | fcntl(fd: c4group_output[0], F_SETFL, O_NONBLOCK); |
| 237 | // Open the update log dialog (this will update itself automatically from c4group_output) |
| 238 | pScreen->ShowRemoveDlg(pDlg: new C4UpdateDlg()); |
| 239 | break; |
| 240 | } |
| 241 | #endif |
| 242 | // done |
| 243 | return succeeded; |
| 244 | } |
| 245 | |
| 246 | bool C4UpdateDlg::IsValidUpdate(const C4GameVersion &rNewVer) |
| 247 | { |
| 248 | // Engine or game version mismatch |
| 249 | if ((rNewVer.iVer[0] != C4XVER1) || (rNewVer.iVer[1] != C4XVER2)) return false; |
| 250 | // Objects major is higher... |
| 251 | if ((rNewVer.iVer[2] > C4XVER3) |
| 252 | // ...or objects major is the same and objects minor is higher... |
| 253 | || ((rNewVer.iVer[2] == C4XVER3) && (rNewVer.iVer[3] > C4XVER4)) |
| 254 | // ...or build number is higher |
| 255 | || (rNewVer.iBuild > C4XVERBUILD)) |
| 256 | // Update okay |
| 257 | return true; |
| 258 | // Otherwise |
| 259 | return false; |
| 260 | } |
| 261 | |
| 262 | bool C4UpdateDlg::CheckForUpdates(C4GUI::Screen *pScreen, bool fAutomatic) |
| 263 | { |
| 264 | // Automatic update only once a day |
| 265 | if (fAutomatic) |
| 266 | if (time(timer: nullptr) - Config.Network.LastUpdateTime < 60 * 60 * 24) |
| 267 | return false; |
| 268 | // Store the time of this update check (whether it's automatic or not or successful or not) |
| 269 | Config.Network.LastUpdateTime = static_cast<int32_t>(time(timer: nullptr)); |
| 270 | // Get current update version from server |
| 271 | C4GameVersion UpdateVersion; |
| 272 | C4GUI::Dialog *pWaitDlg = nullptr; |
| 273 | if (pScreen && C4GUI::IsGUIValid()) |
| 274 | { |
| 275 | pWaitDlg = new C4GUI::MessageDialog(LoadResStr(id: C4ResStrTableKey::IDS_MSG_LOOKINGFORUPDATES), Config.Network.UpdateServerAddress, C4GUI::MessageDialog::btnAbort, C4GUI::Ico_Ex_Update, C4GUI::MessageDialog::dsRegular); |
| 276 | pWaitDlg->SetDelOnClose(false); |
| 277 | pScreen->ShowDialog(pDlg: pWaitDlg, fFade: false); |
| 278 | } |
| 279 | C4Network2VersionInfoClient VerChecker; |
| 280 | bool fSuccess = false, fAborted = false; |
| 281 | StdStrBuf strUpdateRedirect; |
| 282 | const std::string query{std::format(fmt: "{}?action=version" , args: +Config.Network.UpdateServerAddress)}; |
| 283 | if (VerChecker.Init() && VerChecker.SetServer(serverAddress: query) && VerChecker.QueryVersion()) |
| 284 | { |
| 285 | Application.InteractiveThread.AddProc(pProc: &VerChecker); |
| 286 | // wait for version check to terminate |
| 287 | while (VerChecker.isBusy()) |
| 288 | { |
| 289 | C4AppHandleResult hr; |
| 290 | while ((hr = Application.HandleMessage()) == HR_Message) {} |
| 291 | // check for program abort |
| 292 | if (hr == HR_Failure) { fAborted = true; break; } |
| 293 | // check for dialog close |
| 294 | if (pScreen && pWaitDlg) if (!C4GUI::IsGUIValid() || !pWaitDlg->IsShown()) { fAborted = true; break; } |
| 295 | } |
| 296 | if (!fAborted) |
| 297 | { |
| 298 | fSuccess = VerChecker.GetVersion(pSaveToVer: &UpdateVersion); |
| 299 | VerChecker.GetRedirect(rRedirect&: strUpdateRedirect); |
| 300 | } |
| 301 | Application.InteractiveThread.RemoveProc(pProc: &VerChecker); |
| 302 | } |
| 303 | if (pScreen && C4GUI::IsGUIValid()) delete pWaitDlg; |
| 304 | // User abort |
| 305 | if (fAborted) |
| 306 | { |
| 307 | return false; |
| 308 | } |
| 309 | // Error during update check |
| 310 | if (!fSuccess) |
| 311 | { |
| 312 | if (pScreen) |
| 313 | { |
| 314 | StdStrBuf sError; sError.Copy(pnData: LoadResStr(id: C4ResStrTableKey::IDS_MSG_UPDATEFAILED)); |
| 315 | const char *szErrMsg = VerChecker.GetError(); |
| 316 | if (szErrMsg) |
| 317 | { |
| 318 | sError.Append(pnData: ": " ); |
| 319 | sError.Append(pnData: szErrMsg); |
| 320 | } |
| 321 | pScreen->ShowMessage(szMessage: sError.getData(), szCaption: Config.Network.UpdateServerAddress, icoIcon: C4GUI::Ico_Ex_Update); |
| 322 | } |
| 323 | return false; |
| 324 | } |
| 325 | |
| 326 | // UpdateServer Redirection |
| 327 | if ((strUpdateRedirect.getLength() > 0) && !SEqual(szStr1: strUpdateRedirect.getData(), szStr2: Config.Network.UpdateServerAddress)) |
| 328 | { |
| 329 | // this is a new redirect. Inform the user and auto-change servers if desired |
| 330 | const char *newServer = strUpdateRedirect.getData(); |
| 331 | if (pScreen) |
| 332 | { |
| 333 | const std::string message{LoadResStr(id: C4ResStrTableKey::IDS_NET_SERVERREDIRECTMSG, args&: newServer)}; |
| 334 | if (!pScreen->ShowMessageModal(szMessage: message.c_str(), szCaption: LoadResStr(id: C4ResStrTableKey::IDS_NET_SERVERREDIRECT), dwButtons: C4GUI::MessageDialog::btnYesNo, icoIcon: C4GUI::Ico_OfficialServer)) |
| 335 | { |
| 336 | // apply new server setting |
| 337 | SCopy(szSource: newServer, sTarget: Config.Network.UpdateServerAddress, iMaxL: CFG_MaxString); |
| 338 | Config.Save(); |
| 339 | pScreen->ShowMessageModal(szMessage: LoadResStr(id: C4ResStrTableKey::IDS_NET_SERVERREDIRECTDONE), szCaption: LoadResStr(id: C4ResStrTableKey::IDS_NET_SERVERREDIRECT), dwButtons: C4GUI::MessageDialog::btnOK, icoIcon: C4GUI::Ico_OfficialServer); |
| 340 | // abort the update check - user should try again |
| 341 | return false; |
| 342 | } |
| 343 | } |
| 344 | else |
| 345 | { |
| 346 | SCopy(szSource: newServer, sTarget: Config.Network.UpdateServerAddress, iMaxL: CFG_MaxString); |
| 347 | Config.Save(); |
| 348 | return false; |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | if (!pScreen) |
| 353 | { |
| 354 | return C4UpdateDlg::IsValidUpdate(rNewVer: UpdateVersion); |
| 355 | } |
| 356 | |
| 357 | // Applicable update available |
| 358 | if (C4UpdateDlg::IsValidUpdate(rNewVer: UpdateVersion)) |
| 359 | { |
| 360 | // Prompt user, then apply update |
| 361 | const std::string message{LoadResStr(id: C4ResStrTableKey::IDS_MSG_ANUPDATETOVERSIONISAVAILA, args: UpdateVersion.GetString())}; |
| 362 | if (pScreen->ShowMessageModal(szMessage: message.c_str(), szCaption: Config.Network.UpdateServerAddress, dwButtons: C4GUI::MessageDialog::btnYesNo, icoIcon: C4GUI::Ico_Ex_Update)) |
| 363 | if (!DoUpdate(rUpdateVersion: UpdateVersion, pScreen)) |
| 364 | pScreen->ShowMessage(szMessage: LoadResStr(id: C4ResStrTableKey::IDS_MSG_UPDATEFAILED), szCaption: Config.Network.UpdateServerAddress, icoIcon: C4GUI::Ico_Ex_Update); |
| 365 | else |
| 366 | return true; |
| 367 | } |
| 368 | // No applicable update available |
| 369 | else |
| 370 | { |
| 371 | // Message (if not automatic) |
| 372 | if (!fAutomatic) |
| 373 | pScreen->ShowMessage(szMessage: LoadResStr(id: C4ResStrTableKey::IDS_MSG_NOUPDATEAVAILABLEFORTHISV), szCaption: Config.Network.UpdateServerAddress, icoIcon: C4GUI::Ico_Ex_Update); |
| 374 | } |
| 375 | // Done (and no update has been done) |
| 376 | return false; |
| 377 | } |
| 378 | |
| 379 | // *** C4Network2VersionInfoClient |
| 380 | |
| 381 | bool C4Network2VersionInfoClient::QueryVersion() |
| 382 | { |
| 383 | // Perform an Query query |
| 384 | return Query(Data: StdBuf{}, binary: false); |
| 385 | } |
| 386 | |
| 387 | bool C4Network2VersionInfoClient::GetVersion(C4GameVersion *piVerOut) |
| 388 | { |
| 389 | // Sanity check |
| 390 | if (isBusy() || !isSuccess()) return false; |
| 391 | // Parse response |
| 392 | piVerOut->Set(szEngine: "" , iVer1: 0, iVer2: 0, iVer3: 0, iVer4: 0, iVerBuild: 0); |
| 393 | try |
| 394 | { |
| 395 | CompileFromBuf<StdCompilerINIRead>(TargetStruct: mkNamingAdapt( |
| 396 | rValue: mkNamingAdapt( |
| 397 | rValue: mkParAdapt(rObj&: *piVerOut, rPar: false), |
| 398 | szName: "Version" ), |
| 399 | C4ENGINENAME), SrcBuf: getResultString()); |
| 400 | } |
| 401 | catch (const StdCompiler::Exception &e) |
| 402 | { |
| 403 | SetError(e.what()); |
| 404 | return false; |
| 405 | } |
| 406 | // validate version |
| 407 | if (!piVerOut->iVer[0]) |
| 408 | { |
| 409 | SetError(LoadResStr(id: C4ResStrTableKey::IDS_ERR_INVALIDREPLYFROMSERVER)); |
| 410 | return false; |
| 411 | } |
| 412 | // done; version OK! |
| 413 | return true; |
| 414 | } |
| 415 | |
| 416 | bool C4Network2VersionInfoClient::GetRedirect(StdStrBuf &rRedirect) |
| 417 | { |
| 418 | // Sanity check |
| 419 | if (isBusy() || !isSuccess()) return false; |
| 420 | StdStrBuf strUpdateRedirect; |
| 421 | try |
| 422 | { |
| 423 | CompileFromBuf<StdCompilerINIRead>(TargetStruct: mkNamingAdapt( |
| 424 | rValue: mkNamingAdapt(rValue: mkParAdapt(rObj&: strUpdateRedirect, rPar: StdCompiler::RCT_All), szName: "UpdateServerRedirect" , rDefault: "" ), |
| 425 | C4ENGINENAME), SrcBuf: getResultString()); |
| 426 | } |
| 427 | catch (const StdCompiler::Exception &e) |
| 428 | { |
| 429 | SetError(e.what()); |
| 430 | return false; |
| 431 | } |
| 432 | // did we get something? |
| 433 | if (strUpdateRedirect.getLength() > 0) |
| 434 | { |
| 435 | rRedirect.Copy(Buf2: strUpdateRedirect); |
| 436 | return true; |
| 437 | } |
| 438 | // no, we didn't |
| 439 | rRedirect.Clear(); |
| 440 | return false; |
| 441 | } |
| 442 | |