diff --git a/BackgroundAgent.cs b/BackgroundAgent.cs index 96e6609..07cdfd0 100644 --- a/BackgroundAgent.cs +++ b/BackgroundAgent.cs @@ -113,7 +113,7 @@ private static BackgroundAgentSettings ReadBackgroundAgentSettingsUnlocked() if (!File.Exists(path)) return new BackgroundAgentSettings(); FileInfo info = new FileInfo(path); if (info.Length <= 0 || info.Length > BackgroundAgentSettingsMaximumBytes) - throw new InvalidDataException("백그라운드 에이전트 설정 파일 크기가 올바르지 않습니다."); + throw Localized(new InvalidDataException("백그라운드 에이전트 설정 파일 크기가 올바르지 않습니다."), "The background agent settings file size is invalid."); BackgroundAgentSettings settings; try { @@ -121,17 +121,17 @@ private static BackgroundAgentSettings ReadBackgroundAgentSettingsUnlocked() } catch (Exception exception) { - throw new InvalidDataException("백그라운드 에이전트 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception); + throw Localized(new InvalidDataException("백그라운드 에이전트 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception), "The background agent settings file is damaged. The original file was left unchanged."); } if (settings == null || settings.SchemaVersion != BackgroundAgentSchemaVersion) - throw new InvalidDataException("지원하지 않는 백그라운드 에이전트 설정 버전입니다."); + throw Localized(new InvalidDataException("지원하지 않는 백그라운드 에이전트 설정 버전입니다."), "Unsupported background agent settings version."); return settings; } private static void WriteBackgroundAgentSettings(BackgroundAgentSettings settings) { if (settings == null || settings.SchemaVersion != BackgroundAgentSchemaVersion) - throw new InvalidDataException("지원하지 않는 백그라운드 에이전트 설정 버전입니다."); + throw Localized(new InvalidDataException("지원하지 않는 백그라운드 에이전트 설정 버전입니다."), "Unsupported background agent settings version."); lock (BackgroundAgentSettingsLock) { WithBackgroundAgentSettingsLock(delegate @@ -157,7 +157,7 @@ private static T WithBackgroundAgentSettingsLock(Func action) { try { entered = mutex.WaitOne(TimeSpan.FromSeconds(5)); } catch (AbandonedMutexException) { entered = true; } - if (!entered) throw new IOException("다른 MineHarbor 프로세스가 백그라운드 설정을 갱신하고 있습니다."); + if (!entered) throw Localized(new IOException("다른 MineHarbor 프로세스가 백그라운드 설정을 갱신하고 있습니다."), "Another MineHarbor process is updating the background settings."); return action(); } finally { if (entered) mutex.ReleaseMutex(); } @@ -280,7 +280,7 @@ private static void StopBackgroundAgentForLauncherUpdate() if (!IsBackgroundAgentRunning()) return; Thread.Sleep(100); } - throw new IOException("백그라운드 에이전트를 안전하게 종료하지 못해 런처 업데이트를 중단했습니다."); + throw Localized(new IOException("백그라운드 에이전트를 안전하게 종료하지 못해 런처 업데이트를 중단했습니다."), "The launcher update was cancelled because the background agent could not be stopped safely."); } private static BackgroundAgentResponse SendBackgroundAgentRequest(string command, string profile, string value, int timeoutMilliseconds) @@ -296,7 +296,7 @@ private static BackgroundAgentResponse SendBackgroundAgentRequest(string command using (StreamWriter writer = new StreamWriter(client, new UTF8Encoding(false), 4096, true) { AutoFlush = true }) { string request = new JavaScriptSerializer().Serialize(new BackgroundAgentRequest { Command = command, Profile = profile, Value = value }); - if (request.Length > 16384) throw new InvalidDataException("백그라운드 요청이 너무 큽니다."); + if (request.Length > 16384) throw Localized(new InvalidDataException("백그라운드 요청이 너무 큽니다."), "The background request is too large."); int timeout = Math.Max(100, timeoutMilliseconds); Task writeTask = writer.WriteLineAsync(request); if (!writeTask.Wait(timeout)) return null; @@ -323,7 +323,7 @@ private static void SetBackgroundAgentStartupRegistration(bool enabled, string e } using (RegistryKey key = Registry.CurrentUser.CreateSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run")) { - if (key == null) throw new InvalidOperationException("Windows 자동 시작 설정을 열지 못했습니다."); + if (key == null) throw Localized(new InvalidOperationException("Windows 자동 시작 설정을 열지 못했습니다."), "Could not open the Windows startup settings."); if (enabled) key.SetValue(BackgroundAgentRunValueName, QuoteCommandLineArgument(Path.GetFullPath(executablePath)) + " --background-agent", RegistryValueKind.String); else key.DeleteValue(BackgroundAgentRunValueName, false); } @@ -417,7 +417,7 @@ private static NamedPipeServerStream CreateSecuredAgentPipe() PipeSecurity security = new PipeSecurity(); SecurityIdentifier currentUser; using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) currentUser = identity.User; - if (currentUser == null) throw new InvalidOperationException("현재 Windows 사용자 SID를 확인하지 못했습니다."); + if (currentUser == null) throw Localized(new InvalidOperationException("현재 Windows 사용자 SID를 확인하지 못했습니다."), "Could not determine the current Windows user SID."); security.SetAccessRuleProtection(true, false); security.AddAccessRule(new PipeAccessRule(currentUser, PipeAccessRights.FullControl, AccessControlType.Allow)); return new NamedPipeServerStream(GetBackgroundAgentPipeName(), PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 65536, 65536, security); @@ -430,7 +430,7 @@ private async Task HandlePipeClientAsync(NamedPipeServerStream pipe) { Task readTask = reader.ReadLineAsync(); Task completed = await Task.WhenAny(readTask, Task.Delay(3000, cancellation.Token)).ConfigureAwait(false); - if (completed != readTask) throw new IOException("IPC 요청 수신 시간이 초과되었습니다."); + if (completed != readTask) throw Localized(new IOException("IPC 요청 수신 시간이 초과되었습니다."), "Timed out while receiving the IPC request."); string line = await readTask.ConfigureAwait(false); BackgroundAgentResponse response; if (string.IsNullOrEmpty(line) || line.Length > 16384) @@ -571,7 +571,7 @@ private async Task ExecuteAutomationClaimAsync(ManagedProfileRecord profile, Aut } else if (IsLocalTcpPortListening(profile.Port)) { - throw new InvalidOperationException("에이전트가 소유하지 않은 실행 중 서버는 안전하게 백업할 수 없습니다."); + throw Localized(new InvalidOperationException("에이전트가 소유하지 않은 실행 중 서버는 안전하게 백업할 수 없습니다."), "A running server not owned by the agent cannot be backed up safely."); } string path = await CreateAgentBackupAsync(profile.Directory, configuration, "scheduled", cancellation.Token).ConfigureAwait(false); result = ManagedText("백업 완료: ", "Backup completed: ") + Path.GetFileName(path); @@ -678,7 +678,7 @@ private Task StartImmediateBackupAsync(string profileName) SendSessionCommand(session, "save-all flush"); await Task.Delay(1000, cancellation.Token).ConfigureAwait(false); } - else if (IsLocalTcpPortListening(profile.Port)) throw new InvalidOperationException("에이전트가 소유하지 않은 실행 중 서버는 안전하게 백업할 수 없습니다."); + else if (IsLocalTcpPortListening(profile.Port)) throw Localized(new InvalidOperationException("에이전트가 소유하지 않은 실행 중 서버는 안전하게 백업할 수 없습니다."), "A running server not owned by the agent cannot be backed up safely."); string path = await CreateAgentBackupAsync(profile.Directory, configuration, "manual-agent", cancellation.Token).ConfigureAwait(false); TryRecordOperationEvent(profile.Directory, "backup", "info", "백그라운드 에이전트 백업을 완료했습니다.", "Background agent backup completed.", "background-agent", false); return path; @@ -739,7 +739,7 @@ private BackgroundAgentResponse AdoptProfile(string profileName, string serializ { process = Process.GetProcessById(descriptor.ChildProcessId); if (process.StartTime.Ticks != descriptor.ChildProcessStartTicks) - throw new InvalidOperationException("관리 서버 프로세스 시작 시간이 바뀌었습니다."); + throw Localized(new InvalidOperationException("관리 서버 프로세스 시작 시간이 바뀌었습니다."), "The managed server process start time changed."); int agentProcessId; long agentProcessStartTicks; using (Process current = Process.GetCurrentProcess()) @@ -769,7 +769,7 @@ private BackgroundAgentResponse AdoptProfile(string profileName, string serializ { BackgroundAgentSession concurrent; if (sessions.TryGetValue(profile.Name, out concurrent) && IsSessionRunning(concurrent)) - throw new InvalidOperationException("같은 프로필의 서버가 동시에 등록되었습니다."); + throw Localized(new InvalidOperationException("같은 프로필의 서버가 동시에 등록되었습니다."), "A server for the same profile was registered at the same time."); transferResponse = SendManagedChildControlRequest(descriptor.PipeName, transferRequest, 3000); bool transferred = IsManagedChildOwnedBy( transferResponse, @@ -884,7 +884,7 @@ private BackgroundAgentResponse StartProfile(string profileName, bool automaticR process.OutputDataReceived += delegate(object sender, DataReceivedEventArgs eventArgs) { if (eventArgs.Data != null) session.AddLine(eventArgs.Data); }; process.ErrorDataReceived += delegate(object sender, DataReceivedEventArgs eventArgs) { if (eventArgs.Data != null) session.AddLine(eventArgs.Data); }; process.Exited += delegate { HandleSessionExit(session); }; - if (!process.Start()) throw new InvalidOperationException("관리 서버 프로세스를 시작하지 못했습니다."); + if (!process.Start()) throw Localized(new InvalidOperationException("관리 서버 프로세스를 시작하지 못했습니다."), "Could not start the managed server process."); session.Process = process; session.Status = ManagedText("실행 중", "Running"); process.BeginOutputReadLine(); @@ -934,7 +934,7 @@ private BackgroundAgentResponse SendProfileCommand(string profileName, string co private static void SendSessionCommand(BackgroundAgentSession session, string command) { - if (!IsSessionRunning(session)) throw new InvalidOperationException("서버가 실행 중이 아닙니다."); + if (!IsSessionRunning(session)) throw Localized(new InvalidOperationException("서버가 실행 중이 아닙니다."), "The server is not running."); if (!string.IsNullOrWhiteSpace(session.ControlPipeName) && !string.IsNullOrWhiteSpace(session.ControlToken)) { ManagedChildControlRequest request = NewManagedChildControlRequest(session.ControlToken, "command", command); @@ -1349,7 +1349,7 @@ private void SaveSettings() SetBackgroundAgentStartupRegistration(previous.Enabled && previous.StartWithWindows, AssemblyLocation()); } catch { } - ShowMineHarborDialog(this, (korean ? "설정을 저장하지 못했습니다: " : "Could not save settings: ") + exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + ShowMineHarborDialog(this, (korean ? "설정을 저장하지 못했습니다: " : "Could not save settings: ") + DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } } } diff --git a/BackupAndProfileTools.cs b/BackupAndProfileTools.cs index dfd510e..e441100 100644 --- a/BackupAndProfileTools.cs +++ b/BackupAndProfileTools.cs @@ -298,7 +298,7 @@ private void RunBackupWork(string status, Func work, bool reload) TryPostToUi(this, (MethodInvoker)delegate { SetBackupBusy(false, (IsBackupKorean() ? "작업 실패: " : "Operation failed: ") + exception.Message); - ShowMineHarborDialog(this, exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + ShowMineHarborDialog(this, DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); }); } }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a94465..6fc2143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ Product versions follow [Semantic Versioning](https://semver.org/), while `26.2.45.xx` is a separate internal build number. +## [1.18.0] - 2026-07-27 + +### Korean + +- **영어 UI의 한국어 오류 메시지 수정**: 영어로 사용해도 오류 대화상자에 한국어 문구가 그대로 나오던 문제를 수정했습니다. 예외 타입은 바꾸지 않고 영어 문구만 함께 담아, 기존 예외 처리 동작에는 영향이 없습니다. +- **적용 범위**: 운영 기록, 백그라운드 에이전트, 예약 자동화, Discord 원격 제어, Windows 알림, 서버 휴지통, 저장 위치, 관리 서버 인계의 오류 문구 121개를 한국어·영어로 제공합니다. +- **표시 경로 정리**: 오류를 사용자에게 보여 주는 30곳이 현재 언어에 맞는 문구를 고르도록 했습니다. 아직 영어 문구가 없는 오류는 지금까지와 동일하게 원래 문구를 표시합니다. + +### English + +- **Korean error text in the English UI fixed**: error dialogs showed Korean text even when the launcher was used in English. Exception types are unchanged — the English wording is carried alongside — so existing exception handling behaves exactly as before. +- **Coverage**: 121 error messages across operations history, the background agent, scheduled automation, Discord remote control, Windows notifications, the server trash, storage location, and managed-server handoff are now available in both Korean and English. +- **Display paths updated**: the 30 places that surface errors to users now pick the wording for the current language. Errors that do not yet carry English wording continue to show their original text. ## [1.17.0] - 2026-07-26 ### Korean diff --git a/ContentManagementUi.cs b/ContentManagementUi.cs index 4658b67..0c888a9 100644 --- a/ContentManagementUi.cs +++ b/ContentManagementUi.cs @@ -269,7 +269,7 @@ private async Task ReloadInstalledAsync() EndContentOperation(IsContentUiKorean() ? values.Count + "개 콘텐츠" : values.Count + " content item(s)"); } catch (OperationCanceledException) { EndContentOperation(IsContentUiKorean() ? "작업을 취소했습니다." : "Operation cancelled."); } - catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "콘텐츠 목록 오류: " : "Content list error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "콘텐츠 목록 오류: " : "Content list error: ") + DescribeException(exception)); } } private async Task SearchAsync() @@ -288,7 +288,7 @@ private async Task SearchAsync() EndContentOperation(IsContentUiKorean() ? values.Count + "개를 찾았습니다." : values.Count + " result(s) found."); } catch (OperationCanceledException) { EndContentOperation(IsContentUiKorean() ? "검색을 취소했습니다." : "Search cancelled."); } - catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "검색 오류: " : "Search error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "검색 오류: " : "Search error: ") + DescribeException(exception)); } } private async Task InstallSelectedAsync() @@ -309,7 +309,7 @@ private async Task InstallSelectedAsync() await ReloadInstalledAsync(); } catch (OperationCanceledException) { EndContentOperation(korean ? "설치를 취소했습니다." : "Installation cancelled."); } - catch (Exception exception) { EndContentOperation((korean ? "설치 오류: " : "Installation error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((korean ? "설치 오류: " : "Installation error: ") + DescribeException(exception)); } } private async Task InstallLocalAsync() @@ -328,7 +328,7 @@ private async Task InstallLocalAsync() await ReloadInstalledAsync(); } catch (OperationCanceledException) { EndContentOperation(IsContentUiKorean() ? "설치를 취소했습니다." : "Installation cancelled."); } - catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "파일 설치 오류: " : "File installation error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "파일 설치 오류: " : "File installation error: ") + DescribeException(exception)); } } } @@ -341,7 +341,7 @@ private async Task CheckUpdatesAsync() if (closing) return; RenderInstalledItems(); EndContentOperation(IsContentUiKorean() ? "업데이트 가능 " + available + "개" : available + " update(s) available"); } catch (OperationCanceledException) { EndContentOperation(IsContentUiKorean() ? "확인을 취소했습니다." : "Check cancelled."); } - catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "업데이트 확인 오류: " : "Update check error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "업데이트 확인 오류: " : "Update check error: ") + DescribeException(exception)); } } private async Task UpdateSelectedAsync(bool all) @@ -358,7 +358,7 @@ private async Task UpdateSelectedAsync(bool all) if (closing) return; EndContentOperation(IsContentUiKorean() ? "업데이트를 완료했습니다." : "Updates completed."); await ReloadInstalledAsync(); } catch (OperationCanceledException) { EndContentOperation(IsContentUiKorean() ? "업데이트를 취소했습니다." : "Update cancelled."); } - catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "업데이트 오류: " : "Update error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "업데이트 오류: " : "Update error: ") + DescribeException(exception)); } } private async Task ToggleSelectedAsync() @@ -366,7 +366,7 @@ private async Task ToggleSelectedAsync() InstalledContentItem item = SelectedInstalledItem(); if (item == null) return; CancellationToken token; if (!BeginContentOperation(IsContentUiKorean() ? "상태를 변경하는 중..." : "Changing state...", out token)) return; try { await Task.Run(delegate { token.ThrowIfCancellationRequested(); SetContentEnabled(options.ServerDirectory, item.Entry, !item.Active); }, token); if (closing) return; EndContentOperation(IsContentUiKorean() ? "상태를 변경했습니다." : "State changed."); await ReloadInstalledAsync(); } - catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "상태 변경 오류: " : "State change error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((IsContentUiKorean() ? "상태 변경 오류: " : "State change error: ") + DescribeException(exception)); } } private async Task RemoveSelectedAsync() @@ -376,7 +376,7 @@ private async Task RemoveSelectedAsync() if (ShowMineHarborDialog(this, korean ? "선택한 콘텐츠를 서버에서 제거할까요? 안전을 위해 .mineharbor/content-trash로 이동합니다." : "Remove the selected content from the server? It will be moved to .mineharbor/content-trash for safety.", Text, MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) return; CancellationToken token; if (!BeginContentOperation(korean ? "콘텐츠를 제거하는 중..." : "Removing content...", out token)) return; try { await Task.Run(delegate { token.ThrowIfCancellationRequested(); RemoveContentItem(options.ServerDirectory, item.Entry); }, token); if (closing) return; EndContentOperation(korean ? "콘텐츠를 제거했습니다." : "Content removed."); await ReloadInstalledAsync(); } - catch (Exception exception) { EndContentOperation((korean ? "제거 오류: " : "Removal error: ") + exception.Message); } + catch (Exception exception) { EndContentOperation((korean ? "제거 오류: " : "Removal error: ") + DescribeException(exception)); } } private void RenderInstalledItems() diff --git a/DiscordRemoteManagement.cs b/DiscordRemoteManagement.cs index d5fa0a6..16ab75d 100644 --- a/DiscordRemoteManagement.cs +++ b/DiscordRemoteManagement.cs @@ -107,7 +107,7 @@ private static DiscordRemoteSettings ReadDiscordRemoteSettings() if (!File.Exists(path)) return new DiscordRemoteSettings(); FileInfo info = new FileInfo(path); if (info.Length <= 0 || info.Length > DiscordRemoteSettingsMaximumBytes) - throw new InvalidDataException("Discord 원격 제어 설정 파일 크기가 올바르지 않습니다."); + throw Localized(new InvalidDataException("Discord 원격 제어 설정 파일 크기가 올바르지 않습니다."), "The Discord remote-control settings file size is invalid."); DiscordRemoteSettings settings; try { @@ -116,7 +116,7 @@ private static DiscordRemoteSettings ReadDiscordRemoteSettings() } catch (Exception exception) { - throw new InvalidDataException("Discord 원격 제어 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception); + throw Localized(new InvalidDataException("Discord 원격 제어 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception), "The Discord remote-control settings file is damaged. The original file was left unchanged."); } ValidateDiscordRemoteSettings(settings, settings != null && settings.Enabled); return settings; @@ -133,7 +133,7 @@ private static void WriteDiscordRemoteSettings(DiscordRemoteSettings settings) { string json = new JavaScriptSerializer { MaxJsonLength = DiscordRemoteSettingsMaximumBytes }.Serialize(settings); if (Encoding.UTF8.GetByteCount(json) > DiscordRemoteSettingsMaximumBytes) - throw new InvalidDataException("Discord 원격 제어 설정이 허용 크기를 초과했습니다."); + throw Localized(new InvalidDataException("Discord 원격 제어 설정이 허용 크기를 초과했습니다."), "The Discord remote-control settings exceed the allowed size."); string path = GetDiscordRemoteSettingsPath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); string temporary = path + ".준비중"; @@ -158,7 +158,7 @@ private static T WithDiscordRemoteSettingsLock(Func action) { try { entered = mutex.WaitOne(TimeSpan.FromSeconds(5)); } catch (AbandonedMutexException) { entered = true; } - if (!entered) throw new IOException("다른 MineHarbor 프로세스가 Discord 원격 제어 설정을 갱신하고 있습니다."); + if (!entered) throw Localized(new IOException("다른 MineHarbor 프로세스가 Discord 원격 제어 설정을 갱신하고 있습니다."), "Another MineHarbor process is updating the Discord remote-control settings."); return action(); } finally { if (entered) mutex.ReleaseMutex(); } @@ -169,7 +169,7 @@ private static T WithDiscordRemoteSettingsLock(Func action) private static void ValidateDiscordRemoteSettings(DiscordRemoteSettings settings, bool requireCredential) { if (settings == null || settings.SchemaVersion != DiscordRemoteSettingsSchemaVersion) - throw new InvalidDataException("지원하지 않는 Discord 원격 제어 설정 버전입니다."); + throw Localized(new InvalidDataException("지원하지 않는 Discord 원격 제어 설정 버전입니다."), "Unsupported Discord remote-control settings version."); if (settings.ProtectedBotToken == null) settings.ProtectedBotToken = string.Empty; if (settings.ApplicationId == null) settings.ApplicationId = string.Empty; if (settings.GuildId == null) settings.GuildId = string.Empty; @@ -178,18 +178,18 @@ private static void ValidateDiscordRemoteSettings(DiscordRemoteSettings settings settings.AllowedRoleIds = NormalizeDiscordIdList(settings.AllowedRoleIds, "허용 역할"); settings.AllowedProfiles = NormalizeDiscordProfileList(settings.AllowedProfiles); if (settings.ProtectedBotToken.Length > 4096) - throw new InvalidDataException("암호화된 Discord 봇 토큰 크기가 올바르지 않습니다."); + throw Localized(new InvalidDataException("암호화된 Discord 봇 토큰 크기가 올바르지 않습니다."), "The encrypted Discord bot token size is invalid."); if (!settings.Enabled && !requireCredential) return; if (string.IsNullOrWhiteSpace(settings.ProtectedBotToken)) - throw new InvalidDataException("Discord 봇 토큰을 입력해 주세요."); + throw Localized(new InvalidDataException("Discord 봇 토큰을 입력해 주세요."), "Enter a Discord bot token."); if (!IsDiscordSnowflake(settings.ApplicationId) || !IsDiscordSnowflake(settings.GuildId) || !IsDiscordSnowflake(settings.ChannelId)) - throw new InvalidDataException("Discord 애플리케이션·서버·채널 ID를 확인해 주세요."); + throw Localized(new InvalidDataException("Discord 애플리케이션·서버·채널 ID를 확인해 주세요."), "Check the Discord application, server, and channel IDs."); if (settings.AllowedUserIds.Count == 0 && settings.AllowedRoleIds.Count == 0) - throw new InvalidDataException("허용할 Discord 사용자 또는 역할을 하나 이상 지정해 주세요."); + throw Localized(new InvalidDataException("허용할 Discord 사용자 또는 역할을 하나 이상 지정해 주세요."), "Specify at least one allowed Discord user or role."); if (settings.AllowedProfiles.Count == 0) - throw new InvalidDataException("Discord에서 관리할 서버 프로필을 하나 이상 선택해 주세요."); + throw Localized(new InvalidDataException("Discord에서 관리할 서버 프로필을 하나 이상 선택해 주세요."), "Select at least one server profile to manage from Discord."); } private static List NormalizeDiscordIdList(IEnumerable values, string fieldName) @@ -217,9 +217,9 @@ private static List NormalizeDiscordProfileList(IEnumerable valu { string value = (raw ?? string.Empty).Trim(); if (value.Length == 0) continue; - if (!IsValidProfileName(value)) throw new InvalidDataException("Discord 관리 서버 프로필 이름이 올바르지 않습니다."); + if (!IsValidProfileName(value)) throw Localized(new InvalidDataException("Discord 관리 서버 프로필 이름이 올바르지 않습니다."), "A Discord-managed server profile name is invalid."); if (seen.Add(value)) result.Add(value); - if (result.Count > 100) throw new InvalidDataException("Discord 관리 서버 프로필 수가 허용 범위를 초과했습니다."); + if (result.Count > 100) throw Localized(new InvalidDataException("Discord 관리 서버 프로필 수가 허용 범위를 초과했습니다."), "The number of Discord-managed server profiles exceeds the allowed range."); } return result; } @@ -248,10 +248,10 @@ private static string ProtectDiscordBotToken(string token) private static string UnprotectDiscordBotToken(string protectedToken) { if (string.IsNullOrWhiteSpace(protectedToken) || protectedToken.Length > 4096) - throw new InvalidDataException("저장된 Discord 봇 토큰이 없습니다."); + throw Localized(new InvalidDataException("저장된 Discord 봇 토큰이 없습니다."), "There is no saved Discord bot token."); byte[] protectedBytes; try { protectedBytes = Convert.FromBase64String(protectedToken); } - catch (FormatException exception) { throw new InvalidDataException("암호화된 Discord 봇 토큰 형식이 올바르지 않습니다.", exception); } + catch (FormatException exception) { throw Localized(new InvalidDataException("암호화된 Discord 봇 토큰 형식이 올바르지 않습니다.", exception), "The encrypted Discord bot token format is invalid."); } byte[] clear = null; try { @@ -262,7 +262,7 @@ private static string UnprotectDiscordBotToken(string protectedToken) } catch (CryptographicException exception) { - throw new InvalidDataException("현재 Windows 사용자로 Discord 봇 토큰을 복호화하지 못했습니다.", exception); + throw Localized(new InvalidDataException("현재 Windows 사용자로 Discord 봇 토큰을 복호화하지 못했습니다.", exception), "Could not decrypt the Discord bot token as the current Windows user."); } finally { @@ -274,12 +274,12 @@ private static string UnprotectDiscordBotToken(string protectedToken) private static void ValidateDiscordBotToken(string token) { if (string.IsNullOrWhiteSpace(token) || token.Length < 24 || token.Length > 256) - throw new InvalidDataException("Discord 봇 토큰 길이가 올바르지 않습니다."); + throw Localized(new InvalidDataException("Discord 봇 토큰 길이가 올바르지 않습니다."), "The Discord bot token length is invalid."); for (int index = 0; index < token.Length; index++) { char character = token[index]; if (character < 33 || character > 126) - throw new InvalidDataException("Discord 봇 토큰에 허용되지 않는 문자가 있습니다."); + throw Localized(new InvalidDataException("Discord 봇 토큰에 허용되지 않는 문자가 있습니다."), "The Discord bot token contains characters that are not allowed."); } } @@ -695,7 +695,7 @@ private DiscordRemoteActionResult RunAction(string command, string profile, stri string overridden = DiscordRemoteActionOverride(command, profile, userId, korean); return new DiscordRemoteActionResult { Success = overridden != null, Message = overridden ?? Text(korean, "테스트 작업 실패", "Test action failed") }; } - if (actionHandler == null) throw new InvalidOperationException("Discord 원격 작업 처리기가 없습니다."); + if (actionHandler == null) throw Localized(new InvalidOperationException("Discord 원격 작업 처리기가 없습니다."), "There is no Discord remote action handler."); return actionHandler(new DiscordRemoteAction { Command = command, @@ -878,10 +878,10 @@ private async Task ConnectOnceAsync(string gatewayUrl, CancellationToken cancell socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(20); await socket.ConnectAsync(gateway, cancellationToken).ConfigureAwait(false); Dictionary hello = await ReceiveGatewayPayloadAsync(socket, cancellationToken).ConfigureAwait(false); - if (GetInt(hello, "op") != 10) throw new InvalidDataException("Discord Gateway Hello를 받지 못했습니다."); + if (GetInt(hello, "op") != 10) throw Localized(new InvalidDataException("Discord Gateway Hello를 받지 못했습니다."), "Did not receive the Discord Gateway Hello."); int heartbeatMilliseconds = GetInt(GetDictionary(hello, "d"), "heartbeat_interval"); if (heartbeatMilliseconds < 1000 || heartbeatMilliseconds > 120000) - throw new InvalidDataException("Discord Gateway heartbeat 간격이 올바르지 않습니다."); + throw Localized(new InvalidDataException("Discord Gateway heartbeat 간격이 올바르지 않습니다."), "The Discord Gateway heartbeat interval is invalid."); HeartbeatState heartbeat = new HeartbeatState(); Task heartbeatTask = RunHeartbeatAsync(socket, heartbeatMilliseconds, heartbeat, cancellationToken); if (!string.IsNullOrWhiteSpace(sessionId) && sequence.HasValue) @@ -909,7 +909,7 @@ private async Task ConnectOnceAsync(string gatewayUrl, CancellationToken cancell await SendHeartbeatAsync(socket, cancellationToken).ConfigureAwait(false); continue; } - if (opcode == 7) throw new IOException("Discord Gateway가 재연결을 요청했습니다."); + if (opcode == 7) throw Localized(new IOException("Discord Gateway가 재연결을 요청했습니다."), "The Discord Gateway requested a reconnect."); if (opcode == 9) { bool resumable = payload.ContainsKey("d") && Convert.ToBoolean(payload["d"], CultureInfo.InvariantCulture); @@ -919,7 +919,7 @@ private async Task ConnectOnceAsync(string gatewayUrl, CancellationToken cancell resumeGatewayUrl = null; sequence = null; } - throw new IOException("Discord Gateway 세션이 유효하지 않습니다."); + throw Localized(new IOException("Discord Gateway 세션이 유효하지 않습니다."), "The Discord Gateway session is not valid."); } if (opcode != 0) continue; string eventName = GetString(payload, "t"); @@ -929,7 +929,7 @@ private async Task ConnectOnceAsync(string gatewayUrl, CancellationToken cancell sessionId = GetString(eventData, "session_id"); resumeGatewayUrl = GetString(eventData, "resume_gateway_url"); if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(resumeGatewayUrl)) - throw new InvalidDataException("Discord Gateway 세션 정보가 없습니다."); + throw Localized(new InvalidDataException("Discord Gateway 세션 정보가 없습니다."), "The Discord Gateway session information is missing."); CreateGatewayUri(resumeGatewayUrl); stateChanged(true, "연결됨 · /mineharbor 사용 가능", "Connected · /mineharbor is available"); } @@ -970,7 +970,7 @@ private async Task RunHeartbeatAsync(ClientWebSocket socket, int interval, Heart if (!state.Acknowledged) { try { socket.Abort(); } catch { } - throw new IOException("Discord Gateway heartbeat 응답이 없습니다."); + throw Localized(new IOException("Discord Gateway heartbeat 응답이 없습니다."), "No Discord Gateway heartbeat acknowledgement."); } state.Acknowledged = false; await SendHeartbeatAsync(socket, cancellationToken).ConfigureAwait(false); @@ -1135,7 +1135,7 @@ private async Task> SendDiscordHttpAsync( { string text = await ReadBoundedHttpContentAsync(response.Content, DiscordRemoteMaximumHttpBytes, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) - throw new DiscordCredentialException("봇 토큰, 설치 상태 또는 권한을 확인해 주세요."); + throw Localized(new DiscordCredentialException("봇 토큰, 설치 상태 또는 권한을 확인해 주세요."), "Check the bot token, installation, and permissions."); if ((int)response.StatusCode == 429) { if (allowRetry && attempt == 0) @@ -1144,19 +1144,19 @@ private async Task> SendDiscordHttpAsync( await Task.Delay(TimeSpan.FromSeconds(Math.Max(0.25, Math.Min(60, retrySeconds))), cancellationToken).ConfigureAwait(false); continue; } - throw new HttpRequestException("Discord API 속도 제한으로 요청을 완료하지 못했습니다."); + throw Localized(new HttpRequestException("Discord API 속도 제한으로 요청을 완료하지 못했습니다."), "The request could not be completed because of Discord API rate limiting."); } if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) - throw new DiscordCredentialException("애플리케이션·서버 ID, 봇 설치 상태와 권한을 확인해 주세요."); + throw Localized(new DiscordCredentialException("애플리케이션·서버 ID, 봇 설치 상태와 권한을 확인해 주세요."), "Check the application and server IDs, the bot installation, and its permissions."); if (!response.IsSuccessStatusCode) - throw new HttpRequestException("Discord API 요청이 실패했습니다. HTTP " + ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)); + throw Localized(new HttpRequestException("Discord API 요청이 실패했습니다. HTTP " + ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)), "The Discord API request failed. HTTP " + ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)); if (string.IsNullOrWhiteSpace(text)) return new Dictionary(); try { return new JavaScriptSerializer { MaxJsonLength = DiscordRemoteMaximumHttpBytes }.Deserialize>(text); } - catch (Exception exception) { throw new InvalidDataException("Discord API 응답 형식이 올바르지 않습니다.", exception); } + catch (Exception exception) { throw Localized(new InvalidDataException("Discord API 응답 형식이 올바르지 않습니다.", exception), "The Discord API response format is invalid."); } } } } - throw new HttpRequestException("Discord API 속도 제한으로 요청을 완료하지 못했습니다."); + throw Localized(new HttpRequestException("Discord API 속도 제한으로 요청을 완료하지 못했습니다."), "The request could not be completed because of Discord API rate limiting."); } private static double ReadDiscordRetryAfter(HttpResponseMessage response, string text) @@ -1180,7 +1180,7 @@ private static async Task ReadBoundedHttpContentAsync(HttpContent conten { if (content == null) return string.Empty; if (content.Headers.ContentLength.HasValue && content.Headers.ContentLength.Value > maximumBytes) - throw new InvalidDataException("Discord API 응답이 허용 크기를 초과했습니다."); + throw Localized(new InvalidDataException("Discord API 응답이 허용 크기를 초과했습니다."), "The Discord API response exceeds the allowed size."); using (Stream stream = await content.ReadAsStreamAsync().ConfigureAwait(false)) using (MemoryStream memory = new MemoryStream()) { @@ -1189,7 +1189,7 @@ private static async Task ReadBoundedHttpContentAsync(HttpContent conten { int read = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); if (read <= 0) break; - if (memory.Length + read > maximumBytes) throw new InvalidDataException("Discord API 응답이 허용 크기를 초과했습니다."); + if (memory.Length + read > maximumBytes) throw Localized(new InvalidDataException("Discord API 응답이 허용 크기를 초과했습니다."), "The Discord API response exceeds the allowed size."); memory.Write(buffer, 0, read); } return Encoding.UTF8.GetString(memory.ToArray()); @@ -1251,13 +1251,13 @@ private static object CreateDiscordSubcommand(string name, string description, s private static Uri CreateDiscordApiUri(string path) { if (string.IsNullOrEmpty(path) || !path.StartsWith("/api/v10/", StringComparison.Ordinal)) - throw new InvalidDataException("Discord API 경로가 올바르지 않습니다."); + throw Localized(new InvalidDataException("Discord API 경로가 올바르지 않습니다."), "The Discord API path is invalid."); Uri uri = new Uri("https://discord.com" + path, UriKind.Absolute); if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || !string.Equals(uri.Host, "discord.com", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(uri.UserInfo) || !uri.IsDefaultPort) - throw new InvalidDataException("Discord API 주소가 허용 범위를 벗어났습니다."); + throw Localized(new InvalidDataException("Discord API 주소가 허용 범위를 벗어났습니다."), "The Discord API address is outside the allowed range."); return uri; } @@ -1271,7 +1271,7 @@ private static Uri CreateGatewayUri(string baseUrl) || baseUri.Host.EndsWith(".discord.gg", StringComparison.OrdinalIgnoreCase)) || !string.IsNullOrEmpty(baseUri.UserInfo) || !baseUri.IsDefaultPort) - throw new InvalidDataException("Discord Gateway 주소가 허용 범위를 벗어났습니다."); + throw Localized(new InvalidDataException("Discord Gateway 주소가 허용 범위를 벗어났습니다."), "The Discord Gateway address is outside the allowed range."); UriBuilder builder = new UriBuilder(baseUri); builder.Query = "v=10&encoding=json"; return builder.Uri; @@ -1303,19 +1303,19 @@ private async Task> ReceiveGatewayPayloadAsync(Client { int closeCode = result.CloseStatus.HasValue ? (int)result.CloseStatus.Value : 0; if (closeCode == 4004) - throw new DiscordCredentialException("봇 토큰이 거부되었습니다."); + throw Localized(new DiscordCredentialException("봇 토큰이 거부되었습니다."), "The bot token was rejected."); if (closeCode == 4007 || closeCode == 4009) { sessionId = null; resumeGatewayUrl = null; sequence = null; } - throw new IOException("Discord Gateway 연결이 닫혔습니다. 코드 " + closeCode.ToString(CultureInfo.InvariantCulture)); + throw Localized(new IOException("Discord Gateway 연결이 닫혔습니다. 코드 " + closeCode.ToString(CultureInfo.InvariantCulture)), "The Discord Gateway connection closed. Code " + closeCode.ToString(CultureInfo.InvariantCulture)); } if (result.MessageType != WebSocketMessageType.Text) - throw new InvalidDataException("Discord Gateway가 지원하지 않는 데이터 형식을 보냈습니다."); + throw Localized(new InvalidDataException("Discord Gateway가 지원하지 않는 데이터 형식을 보냈습니다."), "The Discord Gateway sent an unsupported data format."); if (memory.Length + result.Count > DiscordRemoteMaximumGatewayBytes) - throw new InvalidDataException("Discord Gateway 메시지가 허용 크기를 초과했습니다."); + throw Localized(new InvalidDataException("Discord Gateway 메시지가 허용 크기를 초과했습니다."), "The Discord Gateway message exceeds the allowed size."); memory.Write(buffer, 0, result.Count); if (result.EndOfMessage) break; } @@ -1327,7 +1327,7 @@ private async Task> ReceiveGatewayPayloadAsync(Client } catch (Exception exception) { - throw new InvalidDataException("Discord Gateway 메시지 형식이 올바르지 않습니다.", exception); + throw Localized(new InvalidDataException("Discord Gateway 메시지 형식이 올바르지 않습니다.", exception), "The Discord Gateway message format is invalid."); } } } @@ -1335,7 +1335,7 @@ private async Task> ReceiveGatewayPayloadAsync(Client private async Task SendGatewayPayloadAsync(ClientWebSocket socket, object payload, CancellationToken cancellationToken) { byte[] bytes = Encoding.UTF8.GetBytes(new JavaScriptSerializer().Serialize(payload)); - if (bytes.Length > 16384) throw new InvalidDataException("Discord Gateway 송신 메시지가 너무 큽니다."); + if (bytes.Length > 16384) throw Localized(new InvalidDataException("Discord Gateway 송신 메시지가 너무 큽니다."), "The outgoing Discord Gateway message is too large."); await gatewaySendLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { diff --git a/DiscordRemoteUi.cs b/DiscordRemoteUi.cs index 8d9a2d7..e1387d8 100644 --- a/DiscordRemoteUi.cs +++ b/DiscordRemoteUi.cs @@ -946,7 +946,7 @@ private void SaveSettings() catch { } } ShowMineHarborDialog(this, - (korean ? "Discord 원격 제어 설정을 저장하지 못했습니다: " : "Could not save Discord remote-control settings: ") + exception.Message, + (korean ? "Discord 원격 제어 설정을 저장하지 못했습니다: " : "Could not save Discord remote-control settings: ") + DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); diff --git a/Localization.cs b/Localization.cs index 98559d8..3e6384c 100644 --- a/Localization.cs +++ b/Localization.cs @@ -4,6 +4,40 @@ internal static partial class Launcher { + // 예외 메시지는 오랫동안 한국어 한 가지만 있어서 영어 UI에서도 한국어 오류가 그대로 표시됐습니다. + // 예외 타입을 바꾸면 기존 catch 절이 영향을 받으므로, 타입은 그대로 두고 Data에 영어 문구만 덧붙입니다. + // 영어 문구가 없는 예외는 지금까지와 동일하게 원래 메시지를 사용합니다. + private const string LocalizedExceptionEnglishKey = "MineHarbor.MessageEn"; + + private static T Localized(T exception, string english) where T : Exception + { + if (exception != null && !string.IsNullOrWhiteSpace(english)) + { + try { exception.Data[LocalizedExceptionEnglishKey] = english; } + catch (ArgumentException) { } + catch (NotSupportedException) { } + } + return exception; + } + + private static string DescribeException(Exception exception) + { + if (exception == null) return string.Empty; + if (!string.Equals(Localization.CurrentLanguage, Localization.Korean, StringComparison.OrdinalIgnoreCase)) + { + try + { + if (exception.Data != null && exception.Data.Contains(LocalizedExceptionEnglishKey)) + { + string english = Convert.ToString(exception.Data[LocalizedExceptionEnglishKey]); + if (!string.IsNullOrWhiteSpace(english)) return english; + } + } + catch (NotSupportedException) { } + } + return exception.Message ?? string.Empty; + } + internal static class Localization { public const string Korean = "ko"; diff --git a/ManagedServerDashboard.cs b/ManagedServerDashboard.cs index 85f1e75..4235701 100644 --- a/ManagedServerDashboard.cs +++ b/ManagedServerDashboard.cs @@ -1528,7 +1528,7 @@ private void SendManagedCommand() } catch (Exception exception) { - ShowMineHarborDialog(this, exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + ShowMineHarborDialog(this, DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } } } diff --git a/ManagedServerHandoff.cs b/ManagedServerHandoff.cs index 4b92025..60b68a6 100644 --- a/ManagedServerHandoff.cs +++ b/ManagedServerHandoff.cs @@ -78,7 +78,7 @@ private sealed class ManagedChildControlServer : IDisposable public ManagedChildControlServer(string profile, string pipe, string secret) { - if (!IsValidProfileName(profile)) throw new InvalidDataException("관리 서버 프로필 이름이 올바르지 않습니다."); + if (!IsValidProfileName(profile)) throw Localized(new InvalidDataException("관리 서버 프로필 이름이 올바르지 않습니다."), "The managed server profile name is invalid."); ValidateManagedChildControlValues(pipe, secret); profileName = profile; pipeName = pipe; @@ -121,7 +121,7 @@ private async Task HandleClientAsync(NamedPipeServerStream pipe) { Task readTask = reader.ReadLineAsync(); Task completed = await Task.WhenAny(readTask, Task.Delay(3000, cancellation.Token)).ConfigureAwait(false); - if (completed != readTask) throw new IOException("관리 서버 제어 요청 수신 시간이 초과되었습니다."); + if (completed != readTask) throw Localized(new IOException("관리 서버 제어 요청 수신 시간이 초과되었습니다."), "Timed out while receiving the managed-server control request."); string line = await readTask.ConfigureAwait(false); ManagedChildControlResponse response; if (string.IsNullOrEmpty(line) || line.Length > ManagedChildControlRequestMaximumCharacters) @@ -234,8 +234,8 @@ private static void ValidateManagedChildControlValues(string pipeName, string to || pipeName.Length > 100 || !pipeName.StartsWith(ManagedChildControlPipePrefix, StringComparison.Ordinal) || !IsLowerHex(pipeName.Substring(ManagedChildControlPipePrefix.Length), 32)) - throw new InvalidDataException("관리 서버 제어 파이프 이름이 올바르지 않습니다."); - if (!IsLowerHex(token, 64)) throw new InvalidDataException("관리 서버 제어 토큰이 올바르지 않습니다."); + throw Localized(new InvalidDataException("관리 서버 제어 파이프 이름이 올바르지 않습니다."), "The managed-server control pipe name is invalid."); + if (!IsLowerHex(token, 64)) throw Localized(new InvalidDataException("관리 서버 제어 토큰이 올바르지 않습니다."), "The managed-server control token is invalid."); } private static bool IsLowerHex(string value, int length) @@ -276,7 +276,7 @@ private static NamedPipeServerStream CreateSecuredManagedChildPipe(string pipeNa PipeSecurity security = new PipeSecurity(); SecurityIdentifier currentUser; using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) currentUser = identity.User; - if (currentUser == null) throw new InvalidOperationException("현재 Windows 사용자 SID를 확인하지 못했습니다."); + if (currentUser == null) throw Localized(new InvalidOperationException("현재 Windows 사용자 SID를 확인하지 못했습니다."), "Could not determine the current Windows user SID."); security.SetAccessRuleProtection(true, false); security.AddAccessRule(new PipeAccessRule(currentUser, PipeAccessRights.FullControl, AccessControlType.Allow)); return new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous, 65536, 65536, security); @@ -385,7 +385,7 @@ private static ManagedChildControlResponse ManagedChildControlFailure(string mes private static void ConfigureManagedChildOwner(int processId, long processStartTicks) { if (processId <= 0 || processStartTicks <= 0 || !IsExactProcessIdentityAlive(processId, processStartTicks)) - throw new InvalidDataException("관리 서버 부모 프로세스를 확인하지 못했습니다."); + throw Localized(new InvalidDataException("관리 서버 부모 프로세스를 확인하지 못했습니다."), "Could not verify the managed server's parent process."); lock (ManagedChildOwnerLock) { ManagedChildOwnerProcessId = processId; @@ -476,7 +476,7 @@ private static void StartManagedChildOwnerMonitor() private static ManagedChildHandoffDescriptor CreateManagedChildHandoffDescriptor(ManagedServerSession session) { if (session == null || session.Profile == null || session.Process == null) - throw new InvalidOperationException("인계할 관리 서버 세션이 없습니다."); + throw Localized(new InvalidOperationException("인계할 관리 서버 세션이 없습니다."), "There is no managed-server session to hand off."); ValidateManagedChildControlValues(session.ControlPipeName, session.ControlToken); int ownerProcessId; long ownerProcessStartTicks; @@ -500,19 +500,19 @@ private static ManagedChildHandoffDescriptor CreateManagedChildHandoffDescriptor private static ManagedChildHandoffDescriptor ParseManagedChildHandoffDescriptor(string serialized, string expectedProfile) { if (string.IsNullOrEmpty(serialized) || serialized.Length > ManagedChildControlRequestMaximumCharacters) - throw new InvalidDataException("관리 서버 인계 정보 크기가 올바르지 않습니다."); + throw Localized(new InvalidDataException("관리 서버 인계 정보 크기가 올바르지 않습니다."), "The managed-server handoff payload size is invalid."); ManagedChildHandoffDescriptor descriptor; try { descriptor = new JavaScriptSerializer().Deserialize(serialized); } - catch (Exception exception) { throw new InvalidDataException("관리 서버 인계 정보를 읽지 못했습니다.", exception); } + catch (Exception exception) { throw Localized(new InvalidDataException("관리 서버 인계 정보를 읽지 못했습니다.", exception), "Could not read the managed-server handoff payload."); } if (descriptor == null || descriptor.SchemaVersion != ManagedChildControlSchemaVersion) - throw new InvalidDataException("지원하지 않는 관리 서버 인계 정보입니다."); + throw Localized(new InvalidDataException("지원하지 않는 관리 서버 인계 정보입니다."), "Unsupported managed-server handoff payload."); if (!IsValidProfileName(descriptor.Profile) || !string.Equals(descriptor.Profile, expectedProfile, StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException("관리 서버 인계 프로필이 일치하지 않습니다."); + throw Localized(new InvalidDataException("관리 서버 인계 프로필이 일치하지 않습니다."), "The managed-server handoff profile does not match."); ValidateManagedChildControlValues(descriptor.PipeName, descriptor.Token); if (descriptor.ChildProcessId <= 0 || descriptor.ChildProcessStartTicks <= 0 || descriptor.OwnerProcessId <= 0 || descriptor.OwnerProcessStartTicks <= 0) - throw new InvalidDataException("관리 서버 인계 프로세스 정보가 올바르지 않습니다."); + throw Localized(new InvalidDataException("관리 서버 인계 프로세스 정보가 올바르지 않습니다."), "The managed-server handoff process information is invalid."); return descriptor; } diff --git a/NetworkAndPlayerTools.cs b/NetworkAndPlayerTools.cs index 95512e9..f83ae28 100644 --- a/NetworkAndPlayerTools.cs +++ b/NetworkAndPlayerTools.cs @@ -392,7 +392,7 @@ private void ConfirmAndSend(string commandPrefix, string actionKey) catch (Exception exception) { statusLabel.ForeColor = palette.Danger; - statusLabel.Text = ToolText("명령 전송 중 오류가 발생했습니다: ", "Could not send the command: ") + exception.Message; + statusLabel.Text = ToolText("명령 전송 중 오류가 발생했습니다: ", "Could not send the command: ") + DescribeException(exception); } } @@ -963,7 +963,7 @@ private void CopyAddress(string address) catch (Exception exception) { statusLabel.ForeColor = palette.Danger; - statusLabel.Text = ToolText("주소를 복사하지 못했습니다: ", "Could not copy the address: ") + exception.Message; + statusLabel.Text = ToolText("주소를 복사하지 못했습니다: ", "Could not copy the address: ") + DescribeException(exception); } } @@ -984,7 +984,7 @@ private void RequestExternalRecheck() catch (Exception exception) { statusLabel.ForeColor = palette.Danger; - statusLabel.Text = ToolText("외부 재검사를 시작하지 못했습니다: ", "Could not start the external recheck: ") + exception.Message; + statusLabel.Text = ToolText("외부 재검사를 시작하지 못했습니다: ", "Could not start the external recheck: ") + DescribeException(exception); } } @@ -1015,7 +1015,7 @@ private void OpenWebPage(string url) catch (Exception exception) { statusLabel.ForeColor = palette.Danger; - statusLabel.Text = ToolText("페이지를 열지 못했습니다: ", "Could not open the page: ") + exception.Message; + statusLabel.Text = ToolText("페이지를 열지 못했습니다: ", "Could not open the page: ") + DescribeException(exception); } } diff --git a/OperationsHistory.cs b/OperationsHistory.cs index 4e71bd4..3db30f9 100644 --- a/OperationsHistory.cs +++ b/OperationsHistory.cs @@ -50,13 +50,13 @@ private static string GetOperationsHistoryPath(string serverDirectory) string metadata = Path.Combine(root, ".mineharbor"); string path = Path.GetFullPath(Path.Combine(metadata, "operations-history.json")); if (!path.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException("운영 기록 경로가 서버 폴더를 벗어났습니다."); + throw Localized(new InvalidDataException("운영 기록 경로가 서버 폴더를 벗어났습니다."), "The operations-history path is outside the server folder."); if (Directory.Exists(root) && (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) - throw new InvalidDataException("연결 또는 재분석 지점 서버 폴더에는 운영 기록을 저장할 수 없습니다."); + throw Localized(new InvalidDataException("연결 또는 재분석 지점 서버 폴더에는 운영 기록을 저장할 수 없습니다."), "Operations history cannot be stored in a junction or reparse-point server folder."); if (Directory.Exists(metadata) && (File.GetAttributes(metadata) & FileAttributes.ReparsePoint) != 0) - throw new InvalidDataException("연결 또는 재분석 지점에는 운영 기록을 저장할 수 없습니다."); + throw Localized(new InvalidDataException("연결 또는 재분석 지점에는 운영 기록을 저장할 수 없습니다."), "Operations history cannot be stored in a junction or reparse point."); if (File.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) - throw new InvalidDataException("연결 또는 재분석 지점 파일에는 운영 기록을 저장할 수 없습니다."); + throw Localized(new InvalidDataException("연결 또는 재분석 지점 파일에는 운영 기록을 저장할 수 없습니다."), "Operations history cannot be stored in a junction or reparse-point file."); return path; } @@ -74,7 +74,7 @@ private static OperationsHistoryDocument ReadOperationsHistoryUnlocked(string pa if (!File.Exists(path)) return new OperationsHistoryDocument(); FileInfo info = new FileInfo(path); if (info.Length <= 0 || info.Length > OperationsHistoryMaximumBytes) - throw new InvalidDataException("운영 기록 파일 크기가 올바르지 않습니다."); + throw Localized(new InvalidDataException("운영 기록 파일 크기가 올바르지 않습니다."), "The operations-history file size is invalid."); OperationsHistoryDocument document; try { @@ -82,7 +82,7 @@ private static OperationsHistoryDocument ReadOperationsHistoryUnlocked(string pa } catch (Exception exception) { - throw new InvalidDataException("운영 기록 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception); + throw Localized(new InvalidDataException("운영 기록 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception), "The operations-history file is damaged. The original file was left unchanged."); } ValidateOperationsHistory(document); return document; @@ -178,7 +178,7 @@ private static void WriteOperationsHistoryUnlocked(string path, OperationsHistor ValidateOperationsHistory(document); string json = new JavaScriptSerializer { MaxJsonLength = OperationsHistoryMaximumBytes }.Serialize(document); if (Encoding.UTF8.GetByteCount(json) > OperationsHistoryMaximumBytes) - throw new InvalidDataException("운영 기록 파일이 허용 크기를 초과했습니다."); + throw Localized(new InvalidDataException("운영 기록 파일이 허용 크기를 초과했습니다."), "The operations-history file exceeds the allowed size."); Directory.CreateDirectory(Path.GetDirectoryName(path)); string temporary = path + ".준비중"; File.WriteAllText(temporary, json, new UTF8Encoding(false)); @@ -188,12 +188,12 @@ private static void WriteOperationsHistoryUnlocked(string path, OperationsHistor private static void ValidateOperationsHistory(OperationsHistoryDocument document) { if (document == null || document.SchemaVersion != OperationsHistorySchemaVersion) - throw new InvalidDataException("지원하지 않는 운영 기록 스키마입니다."); + throw Localized(new InvalidDataException("지원하지 않는 운영 기록 스키마입니다."), "Unsupported operations-history schema."); if (document.Entries == null) document.Entries = new List(); if (document.Entries.Count > OperationsHistoryMaximumEntries) - throw new InvalidDataException("운영 기록 항목 수가 허용 범위를 초과했습니다."); + throw Localized(new InvalidDataException("운영 기록 항목 수가 허용 범위를 초과했습니다."), "The operations-history entry count exceeds the allowed range."); if (!IsOperationHash(document.ChainAnchor, true)) - throw new InvalidDataException("운영 기록 연결 기준값이 올바르지 않습니다."); + throw Localized(new InvalidDataException("운영 기록 연결 기준값이 올바르지 않습니다."), "The operations-history chain anchor is invalid."); string previous = document.ChainAnchor ?? string.Empty; HashSet identifiers = new HashSet(StringComparer.OrdinalIgnoreCase); DateTime parsed; @@ -201,20 +201,20 @@ private static void ValidateOperationsHistory(OperationsHistoryDocument document { OperationsHistoryEntry entry = document.Entries[i]; if (entry == null || string.IsNullOrWhiteSpace(entry.Id) || entry.Id.Length > 80 || !identifiers.Add(entry.Id)) - throw new InvalidDataException("운영 기록 식별자가 없거나 중복되었습니다."); + throw Localized(new InvalidDataException("운영 기록 식별자가 없거나 중복되었습니다."), "An operations-history identifier is missing or duplicated."); if (!DateTime.TryParse(entry.CreatedUtc, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out parsed)) - throw new InvalidDataException("운영 기록 시각이 올바르지 않습니다."); + throw Localized(new InvalidDataException("운영 기록 시각이 올바르지 않습니다."), "An operations-history timestamp is invalid."); if (!string.Equals(entry.Category, NormalizeOperationCategory(entry.Category), StringComparison.Ordinal) || !string.Equals(entry.Severity, NormalizeOperationSeverity(entry.Severity), StringComparison.Ordinal) || !string.Equals(entry.Source, NormalizeOperationSource(entry.Source), StringComparison.Ordinal)) - throw new InvalidDataException("운영 기록 분류가 올바르지 않습니다."); + throw Localized(new InvalidDataException("운영 기록 분류가 올바르지 않습니다."), "An operations-history category is invalid."); if (string.IsNullOrWhiteSpace(entry.MessageKo) || string.IsNullOrWhiteSpace(entry.MessageEn) || entry.MessageKo.Length > 1000 || entry.MessageEn.Length > 1000) - throw new InvalidDataException("운영 기록 문구가 올바르지 않습니다."); + throw Localized(new InvalidDataException("운영 기록 문구가 올바르지 않습니다."), "An operations-history message is invalid."); if (!string.Equals(entry.PreviousHash ?? string.Empty, previous, StringComparison.OrdinalIgnoreCase) || !IsOperationHash(entry.Hash, false) || !string.Equals(entry.Hash, CalculateOperationEntryHash(entry), StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException("운영 기록 연속 해시가 일치하지 않습니다. 원본 파일은 변경하지 않았습니다."); + throw Localized(new InvalidDataException("운영 기록 연속 해시가 일치하지 않습니다. 원본 파일은 변경하지 않았습니다."), "The operations-history hash chain does not match. The original file was left unchanged."); previous = entry.Hash; } } @@ -360,7 +360,7 @@ private static T WithOperationsHistoryLock(string path, Func action) { try { entered = mutex.WaitOne(TimeSpan.FromSeconds(5)); } catch (AbandonedMutexException) { entered = true; } - if (!entered) throw new IOException("다른 MineHarbor 프로세스가 운영 기록을 갱신하고 있습니다."); + if (!entered) throw Localized(new IOException("다른 MineHarbor 프로세스가 운영 기록을 갱신하고 있습니다."), "Another MineHarbor process is updating the operations history."); return action(); } finally @@ -554,7 +554,7 @@ private void MarkSelectedRead() MarkOperationEventRead(reference.ServerDirectory, reference.EntryId, true); ReloadHistory(); } - catch (Exception exception) { ShowMineHarborDialog(this, exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } + catch (Exception exception) { ShowMineHarborDialog(this, DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void MarkAllRead() @@ -568,7 +568,7 @@ private void MarkAllRead() } ReloadHistory(); } - catch (Exception exception) { ShowMineHarborDialog(this, exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } + catch (Exception exception) { ShowMineHarborDialog(this, DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private void ExportVisibleHistory() diff --git a/QuickCommandUi.cs b/QuickCommandUi.cs index afa1e0a..2f3e8cb 100644 --- a/QuickCommandUi.cs +++ b/QuickCommandUi.cs @@ -809,7 +809,7 @@ private void InstallBridge(object sender, EventArgs eventArgs) RefreshBridgeStatus(); ShowMineHarborDialog(this, LauncherUiText("브리지를 설치했습니다. 다음 서버 시작부터 실시간 자동완성을 사용할 수 있습니다.", "Bridge installed. Live suggestions will be available on the next server start."), Text, MessageBoxButtons.OK, MessageBoxIcon.Information); } - catch (Exception exception) { ShowMineHarborDialog(this, exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } + catch (Exception exception) { ShowMineHarborDialog(this, DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void RemoveBridge(object sender, EventArgs eventArgs) @@ -823,7 +823,7 @@ private void RemoveBridge(object sender, EventArgs eventArgs) WriteBridgeChoice(serverDirectory, "skip"); RefreshBridgeStatus(); } - catch (Exception exception) { ShowMineHarborDialog(this, exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } + catch (Exception exception) { ShowMineHarborDialog(this, DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void OpenBridgeFolder(object sender, EventArgs eventArgs) diff --git a/README.md b/README.md index fc109e6..a3d4cc8 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ | **Windows 설치 프로그램** | 시작 메뉴, 선택적 바탕화면 바로가기, 제거 기능 사용 | **[최신 Release 열기](https://github.com/Mangom72/MineHarbor/releases/latest)** | | **Portable ZIP** | README와 라이선스를 포함한 묶음 보관 | **[최신 Release 열기](https://github.com/Mangom72/MineHarbor/releases/latest)** | -현재 소스 버전은 `v1.17.0`, 내부 빌드는 `26.2.45.85`입니다. MineHarbor 이름으로 배포된 Portable EXE는 같은 링크에서 계속 최신 파일을 받을 수 있습니다. 기존 설치의 `%LOCALAPPDATA%\MinecraftServerLauncher` 데이터는 자동으로 찾아 그대로 사용하며, 새 사용자 데이터 경로는 `%LOCALAPPDATA%\MineHarbor`입니다. +현재 소스 버전은 `v1.18.0`, 내부 빌드는 `26.2.45.86`입니다. MineHarbor 이름으로 배포된 Portable EXE는 같은 링크에서 계속 최신 파일을 받을 수 있습니다. 기존 설치의 `%LOCALAPPDATA%\MinecraftServerLauncher` 데이터는 자동으로 찾아 그대로 사용하며, 새 사용자 데이터 경로는 `%LOCALAPPDATA%\MineHarbor`입니다. -이 README는 로드맵이 아니라 현재 `v1.17.0` 소스와 자동 테스트, 공개 Release 자산에서 확인한 기능만 설명합니다. 서버 종류나 브리지 연결처럼 조건에 따라 달라지는 기능과 지원되지 않는 상태는 아래에 따로 표시합니다. +이 README는 로드맵이 아니라 현재 `v1.18.0` 소스와 자동 테스트, 공개 Release 자산에서 확인한 기능만 설명합니다. 서버 종류나 브리지 연결처럼 조건에 따라 달라지는 기능과 지원되지 않는 상태는 아래에 따로 표시합니다. > [!WARNING] > 현재 릴리스 실행 파일은 요청된 자체서명 인증서로 무결성을 표시하지만 공개 인증 기관이 신뢰한 배포자 서명은 아닙니다. 따라서 Windows SmartScreen 경고가 나타날 수 있습니다. Release의 `SHA256SUMS.txt`와 GitHub 출처를 함께 확인해 주세요. @@ -312,9 +312,9 @@ PR과 `main` 푸시의 일반 CI는 버전·문서 일치, SDK 스타일 `net48` | **Windows installer** | Start Menu, optional desktop shortcut, and uninstall support | **[Open the latest release](https://github.com/Mangom72/MineHarbor/releases/latest)** | | **Portable ZIP** | Keep the launcher, README, and license together | **[Open the latest release](https://github.com/Mangom72/MineHarbor/releases/latest)** | -Current source version: `v1.17.0` · internal build: `26.2.45.85`. MineHarbor releases keep the same permanent Portable URL. Existing data under `%LOCALAPPDATA%\MinecraftServerLauncher` is detected and preserved; new user-data installations use `%LOCALAPPDATA%\MineHarbor`. +Current source version: `v1.18.0` · internal build: `26.2.45.86`. MineHarbor releases keep the same permanent Portable URL. Existing data under `%LOCALAPPDATA%\MinecraftServerLauncher` is detected and preserved; new user-data installations use `%LOCALAPPDATA%\MineHarbor`. -This README documents shipped behavior verified against the current `v1.17.0` source, automated tests, and public release assets—not roadmap items. Conditional and unsupported behavior is called out explicitly below. +This README documents shipped behavior verified against the current `v1.18.0` source, automated tests, and public release assets—not roadmap items. Conditional and unsupported behavior is called out explicitly below. > [!WARNING] > Release executables carry the requested self-signed integrity signature, not a publisher identity trusted by a public certificate authority. Windows SmartScreen can therefore still warn. Verify the GitHub source and the release `SHA256SUMS.txt`. diff --git a/ServerAutomation.cs b/ServerAutomation.cs index 1b658f7..9a84118 100644 --- a/ServerAutomation.cs +++ b/ServerAutomation.cs @@ -77,7 +77,7 @@ private static ServerAutomationConfiguration ReadServerAutomationConfigurationUn FileInfo info = new FileInfo(path); if (info.Length <= 0 || info.Length > AutomationFileMaximumBytes) { - throw new InvalidDataException("자동화 설정 파일 크기가 올바르지 않습니다."); + throw Localized(new InvalidDataException("자동화 설정 파일 크기가 올바르지 않습니다."), "The automation settings file size is invalid."); } ServerAutomationConfiguration configuration; try @@ -86,7 +86,7 @@ private static ServerAutomationConfiguration ReadServerAutomationConfigurationUn } catch (Exception exception) { - throw new InvalidDataException("자동화 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception); + throw Localized(new InvalidDataException("자동화 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception), "The automation settings file is damaged. The original file was left unchanged."); } MigrateServerAutomationConfiguration(configuration); ValidateServerAutomationConfiguration(configuration); @@ -95,9 +95,9 @@ private static ServerAutomationConfiguration ReadServerAutomationConfigurationUn private static void MigrateServerAutomationConfiguration(ServerAutomationConfiguration configuration) { - if (configuration == null) throw new InvalidDataException("자동화 설정 파일이 비어 있습니다."); + if (configuration == null) throw Localized(new InvalidDataException("자동화 설정 파일이 비어 있습니다."), "The automation settings file is empty."); if (configuration.SchemaVersion > AutomationSchemaVersion || configuration.SchemaVersion < 1) - throw new InvalidDataException("지원하지 않는 자동화 설정 버전입니다."); + throw Localized(new InvalidDataException("지원하지 않는 자동화 설정 버전입니다."), "Unsupported automation settings version."); if (configuration.Jobs == null) configuration.Jobs = new List(); if (configuration.SchemaVersion == 1) { @@ -142,7 +142,7 @@ private static T WithAutomationCrossProcessLock(string serverDirectory, Func< { try { entered = mutex.WaitOne(TimeSpan.FromSeconds(5)); } catch (AbandonedMutexException) { entered = true; } - if (!entered) throw new IOException("다른 MineHarbor 프로세스가 예약 설정을 갱신하고 있습니다."); + if (!entered) throw Localized(new IOException("다른 MineHarbor 프로세스가 예약 설정을 갱신하고 있습니다."), "Another MineHarbor process is updating the schedule settings."); return action(); } finally { if (entered) mutex.ReleaseMutex(); } @@ -153,26 +153,26 @@ private static T WithAutomationCrossProcessLock(string serverDirectory, Func< private static void ValidateServerAutomationConfiguration(ServerAutomationConfiguration configuration) { if (configuration == null || configuration.SchemaVersion != AutomationSchemaVersion) - throw new InvalidDataException("지원하지 않는 자동화 설정 버전입니다."); + throw Localized(new InvalidDataException("지원하지 않는 자동화 설정 버전입니다."), "Unsupported automation settings version."); configuration.RetentionCount = Math.Max(1, Math.Min(200, configuration.RetentionCount)); configuration.RetentionDays = Math.Max(1, Math.Min(3650, configuration.RetentionDays)); configuration.RetentionMaximumBytes = Math.Max(104857600L, Math.Min(10995116277760L, configuration.RetentionMaximumBytes)); if (configuration.Jobs == null) configuration.Jobs = new List(); - if (configuration.Jobs.Count > 200) throw new InvalidDataException("예약 작업은 서버당 200개를 넘을 수 없습니다."); + if (configuration.Jobs.Count > 200) throw Localized(new InvalidDataException("예약 작업은 서버당 200개를 넘을 수 없습니다."), "A server cannot have more than 200 scheduled jobs."); HashSet identifiers = new HashSet(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < configuration.Jobs.Count; i++) { ServerAutomationJob job = configuration.Jobs[i]; if (job == null || string.IsNullOrWhiteSpace(job.Id) || job.Id.Length > 80 || !identifiers.Add(job.Id)) - throw new InvalidDataException("예약 작업 식별자가 없거나 중복되었습니다."); + throw Localized(new InvalidDataException("예약 작업 식별자가 없거나 중복되었습니다."), "A scheduled job identifier is missing or duplicated."); if (string.IsNullOrWhiteSpace(job.Name) || job.Name.Length > 120) - throw new InvalidDataException("예약 작업 이름은 1~120자로 입력해야 합니다."); - if (!IsSupportedAutomationAction(job.Action)) throw new InvalidDataException("지원하지 않는 예약 작업 종류입니다."); + throw Localized(new InvalidDataException("예약 작업 이름은 1~120자로 입력해야 합니다."), "A scheduled job name must be 1 to 120 characters."); + if (!IsSupportedAutomationAction(job.Action)) throw Localized(new InvalidDataException("지원하지 않는 예약 작업 종류입니다."), "Unsupported scheduled job type."); if (string.Equals(job.Action, "command", StringComparison.OrdinalIgnoreCase)) ValidateScheduledCommand(job.Command); - if (job.WarningSeconds < 0 || job.WarningSeconds > 3600) throw new InvalidDataException("공지 시간은 0~3600초여야 합니다."); + if (job.WarningSeconds < 0 || job.WarningSeconds > 3600) throw Localized(new InvalidDataException("공지 시간은 0~3600초여야 합니다."), "The warning time must be between 0 and 3600 seconds."); if (string.Equals(job.ScheduleKind, "interval", StringComparison.OrdinalIgnoreCase)) { - if (job.IntervalMinutes < 1 || job.IntervalMinutes > 525600) throw new InvalidDataException("반복 간격은 1분~365일이어야 합니다."); + if (job.IntervalMinutes < 1 || job.IntervalMinutes > 525600) throw Localized(new InvalidDataException("반복 간격은 1분~365일이어야 합니다."), "The repeat interval must be between 1 minute and 365 days."); } else if (string.Equals(job.ScheduleKind, "daily", StringComparison.OrdinalIgnoreCase)) { @@ -187,18 +187,18 @@ private static void ValidateServerAutomationConfiguration(ServerAutomationConfig { DateTime ignored; if (!DateTime.TryParseExact(job.OnceLocalDateTime, "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out ignored)) - throw new InvalidDataException("일회성 실행 시각은 yyyy-MM-dd HH:mm 형식이어야 합니다."); + throw Localized(new InvalidDataException("일회성 실행 시각은 yyyy-MM-dd HH:mm 형식이어야 합니다."), "A one-time run time must use the yyyy-MM-dd HH:mm format."); } - else throw new InvalidDataException("지원하지 않는 예약 방식입니다."); + else throw Localized(new InvalidDataException("지원하지 않는 예약 방식입니다."), "Unsupported schedule kind."); if (!string.Equals(job.MissedRunPolicy, "run-once", StringComparison.OrdinalIgnoreCase) && !string.Equals(job.MissedRunPolicy, "skip", StringComparison.OrdinalIgnoreCase) && !string.Equals(job.MissedRunPolicy, "notify-only", StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException("놓친 작업 처리 방식이 올바르지 않습니다."); + throw Localized(new InvalidDataException("놓친 작업 처리 방식이 올바르지 않습니다."), "The missed-run policy is invalid."); if (job.MaximumDelayMinutes < 1 || job.MaximumDelayMinutes > 525600) - throw new InvalidDataException("최대 지연 시간은 1분~365일이어야 합니다."); + throw Localized(new InvalidDataException("최대 지연 시간은 1분~365일이어야 합니다."), "The maximum delay must be between 1 minute and 365 days."); DateTime parsed; if (!string.IsNullOrEmpty(job.NextRunUtc) && !DateTime.TryParse(job.NextRunUtc, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out parsed)) - throw new InvalidDataException("다음 실행 시각이 올바르지 않습니다."); + throw Localized(new InvalidDataException("다음 실행 시각이 올바르지 않습니다."), "The next run time is invalid."); } } @@ -206,7 +206,7 @@ private static void ValidateAutomationLocalTime(string value) { DateTime ignored; if (!DateTime.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out ignored)) - throw new InvalidDataException("실행 시각은 HH:mm 형식이어야 합니다."); + throw Localized(new InvalidDataException("실행 시각은 HH:mm 형식이어야 합니다."), "A run time must use the HH:mm format."); } private static HashSet ParseAutomationWeekdays(string value) @@ -225,9 +225,9 @@ private static HashSet ParseAutomationWeekdays(string value) found = true; break; } - if (!found) throw new InvalidDataException("실행 요일 값이 올바르지 않습니다."); + if (!found) throw Localized(new InvalidDataException("실행 요일 값이 올바르지 않습니다."), "A run weekday value is invalid."); } - if (days.Count == 0) throw new InvalidDataException("매주 실행할 요일을 하나 이상 선택해야 합니다."); + if (days.Count == 0) throw Localized(new InvalidDataException("매주 실행할 요일을 하나 이상 선택해야 합니다."), "Select at least one weekday for a weekly schedule."); return days; } @@ -243,7 +243,7 @@ private static bool IsSupportedAutomationAction(string action) private static void ValidateScheduledCommand(string command) { if (string.IsNullOrWhiteSpace(command) || command.Length > 2048 || command.IndexOf('\r') >= 0 || command.IndexOf('\n') >= 0 || command.IndexOf('\0') >= 0) - throw new InvalidDataException("예약 명령은 줄바꿈 없이 1~2048자로 입력해야 합니다."); + throw Localized(new InvalidDataException("예약 명령은 줄바꿈 없이 1~2048자로 입력해야 합니다."), "A scheduled command must be 1 to 2048 characters with no line breaks."); } private static DateTime CalculateNextAutomationRunUtc(ServerAutomationJob job, DateTime afterUtc) @@ -267,7 +267,7 @@ private static DateTime CalculateNextAutomationRunUtc(ServerAutomationJob job, D DateTime weekly = new DateTime(date.Year, date.Month, date.Day, time.Hour, time.Minute, 0, DateTimeKind.Local); if (weekly > localAfter) return weekly.ToUniversalTime(); } - throw new InvalidDataException("다음 주간 실행 시각을 계산하지 못했습니다."); + throw Localized(new InvalidDataException("다음 주간 실행 시각을 계산하지 못했습니다."), "Could not calculate the next weekly run time."); } DateTime candidate = new DateTime(localAfter.Year, localAfter.Month, localAfter.Day, time.Hour, time.Minute, 0, DateTimeKind.Local); if (candidate <= localAfter) candidate = candidate.AddDays(1); @@ -601,7 +601,7 @@ private void SaveConfiguration() catch (Exception exception) { ShowAutomationError(exception); } } - private void ShowAutomationError(Exception exception) { ShowMineHarborDialog(this, ManagedText("자동화 설정을 처리하지 못했습니다: ", "Could not process automation settings: ") + exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } + private void ShowAutomationError(Exception exception) { ShowMineHarborDialog(this, ManagedText("자동화 설정을 처리하지 못했습니다: ", "Could not process automation settings: ") + DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private sealed class AutomationJobForm : Form @@ -782,7 +782,7 @@ private void SaveJob() throw new InvalidDataException(ManagedText("이미 지난 일회성 시각은 저장할 수 없습니다. 지금 실행을 사용하거나 미래 시각을 선택해 주세요.", "A past one-time date cannot be saved. Use Run now or choose a future date.")); Job = value; DialogResult = DialogResult.OK; Close(); } - catch (Exception exception) { ShowMineHarborDialog(this, exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } + catch (Exception exception) { ShowMineHarborDialog(this, DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } } private ServerAutomationJob BuildJobFromFields() @@ -832,7 +832,7 @@ private void UpdateJobPreview() } catch (Exception exception) { - previewLabel.Text = ManagedText("미리보기: ", "Preview: ") + exception.Message; + previewLabel.Text = ManagedText("미리보기: ", "Preview: ") + DescribeException(exception); } previewLabel.AccessibleDescription = previewLabel.Text; } diff --git a/ServerManagementFeatures.cs b/ServerManagementFeatures.cs index 1c9365d..61176ae 100644 --- a/ServerManagementFeatures.cs +++ b/ServerManagementFeatures.cs @@ -213,12 +213,12 @@ private async void ObserveAutomationPollAsync() List claims = ClaimDueAutomationJobs(profiles[i].Directory, DateTime.UtcNow); for (int claimIndex = 0; claimIndex < claims.Count; claimIndex++) ObserveManagedAutomationJobAsync(claims[claimIndex]); } - catch (InvalidDataException exception) { summaryLabel.Text = profiles[i].Name + " · " + ManagedText("자동화 설정 오류: ", "Automation configuration error: ") + exception.Message; } + catch (InvalidDataException exception) { summaryLabel.Text = profiles[i].Name + " · " + ManagedText("자동화 설정 오류: ", "Automation configuration error: ") + DescribeException(exception); } } await Task.Yield(); } catch (OperationCanceledException) { } - catch (Exception exception) { if (!IsDisposed) summaryLabel.Text = ManagedText("예약 작업 검사 실패: ", "Schedule check failed: ") + exception.Message; } + catch (Exception exception) { if (!IsDisposed) summaryLabel.Text = ManagedText("예약 작업 검사 실패: ", "Schedule check failed: ") + DescribeException(exception); } finally { automationPollRunning = false; } } @@ -566,7 +566,7 @@ private async void RefreshSnapshotAsync() refreshedLabel.Text = ManagedText("최근 갱신: ", "Last refreshed: ") + DateTime.Now.ToString("T", CultureInfo.CurrentCulture); } catch (OperationCanceledException) { } - catch (Exception exception) { if (!IsDisposed) refreshedLabel.Text = ManagedText("상태 수집 실패: ", "Status collection failed: ") + exception.Message; } + catch (Exception exception) { if (!IsDisposed) refreshedLabel.Text = ManagedText("상태 수집 실패: ", "Status collection failed: ") + DescribeException(exception); } finally { refreshing = false; } } } diff --git a/ServerTrash.cs b/ServerTrash.cs index e1d20f0..b520bd0 100644 --- a/ServerTrash.cs +++ b/ServerTrash.cs @@ -28,9 +28,9 @@ private static string GetServerTrashRoot(string serversRoot) private static ServerTrashRecord MoveProfileToServerTrash(string serversRoot, ManagedProfileRecord profile, DateTime utcNow) { - if (profile == null || !IsValidProfileName(profile.Name)) throw new InvalidDataException("삭제할 서버 프로필 정보가 올바르지 않습니다."); + if (profile == null || !IsValidProfileName(profile.Name)) throw Localized(new InvalidDataException("삭제할 서버 프로필 정보가 올바르지 않습니다."), "The server profile to delete is invalid."); EnsureSafeProfilePath(serversRoot, profile.Directory); - if (!Directory.Exists(profile.Directory)) throw new DirectoryNotFoundException("삭제할 서버 폴더를 찾을 수 없습니다."); + if (!Directory.Exists(profile.Directory)) throw Localized(new DirectoryNotFoundException("삭제할 서버 폴더를 찾을 수 없습니다."), "The server folder to delete was not found."); string trashRoot = GetServerTrashRoot(serversRoot); Directory.CreateDirectory(trashRoot); string folderName = ToSafeDirectoryName(profile.Name) + "-" + utcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + "-" + Guid.NewGuid().ToString("N").Substring(0, 8); @@ -136,12 +136,12 @@ private static int PurgeExpiredServerTrash(string serversRoot, DateTime utcNow) private static void RestoreServerTrashRecord(string serversRoot, ServerTrashRecord record, string restoreName) { - if (record == null || !IsValidProfileName(restoreName)) throw new InvalidDataException("복구할 서버 이름이 올바르지 않습니다."); + if (record == null || !IsValidProfileName(restoreName)) throw Localized(new InvalidDataException("복구할 서버 이름이 올바르지 않습니다."), "The server name to restore is invalid."); string trashRoot = GetServerTrashRoot(serversRoot); EnsurePathInsideRoot(trashRoot, record.Directory); string destination = GetProfileDirectory(serversRoot, restoreName); EnsureSafeProfilePath(serversRoot, destination); - if (Directory.Exists(destination)) throw new IOException("같은 이름의 서버가 이미 있습니다."); + if (Directory.Exists(destination)) throw Localized(new IOException("같은 이름의 서버가 이미 있습니다."), "A server with the same name already exists."); Directory.Move(record.Directory, destination); try { @@ -167,13 +167,13 @@ private static void PermanentlyDeleteServerTrashRecord(string serversRoot, Serve EnsurePathInsideRoot(trashRoot, record.Directory); DirectoryInfo info = new DirectoryInfo(record.Directory); if (!info.Exists) return; - if ((info.Attributes & FileAttributes.ReparsePoint) != 0) throw new InvalidDataException("연결된 폴더는 영구 삭제할 수 없습니다."); + if ((info.Attributes & FileAttributes.ReparsePoint) != 0) throw Localized(new InvalidDataException("연결된 폴더는 영구 삭제할 수 없습니다."), "A linked folder cannot be permanently deleted."); DeleteDirectoryTreeWithoutFollowingLinks(info, 0); } private static void DeleteDirectoryTreeWithoutFollowingLinks(DirectoryInfo directory, int depth) { - if (depth > 128) throw new InvalidDataException("삭제할 서버 폴더 단계가 지나치게 깊습니다."); + if (depth > 128) throw Localized(new InvalidDataException("삭제할 서버 폴더 단계가 지나치게 깊습니다."), "The server folder to delete is nested too deeply."); FileInfo[] files = directory.GetFiles(); for (int i = 0; i < files.Length; i++) { @@ -363,7 +363,7 @@ private void RunTrashAction(Action action) } catch (Exception exception) { - ShowMineHarborDialog(this, IsBackupKorean() ? "휴지통 작업을 완료하지 못했습니다: " + exception.Message : "Could not complete the trash operation: " + exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + ShowMineHarborDialog(this, IsBackupKorean() ? "휴지통 작업을 완료하지 못했습니다: " + DescribeException(exception) : "Could not complete the trash operation: " + DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); } } } diff --git a/StorageConfiguration.cs b/StorageConfiguration.cs index 0f34236..983bbe7 100644 --- a/StorageConfiguration.cs +++ b/StorageConfiguration.cs @@ -162,7 +162,7 @@ private static void SaveDataStorageSettings(string mode, string customPath) !string.Equals(mode, StorageModePortable, StringComparison.Ordinal) && !string.Equals(mode, StorageModeCustom, StringComparison.Ordinal)) { - throw new InvalidDataException("데이터 저장 위치 방식을 인식할 수 없습니다."); + throw Localized(new InvalidDataException("데이터 저장 위치 방식을 인식할 수 없습니다."), "The data storage location mode is not recognized."); } Directory.CreateDirectory(GetLauncherUserDataDirectory()); string path = GetStorageSettingsPath(); @@ -213,7 +213,7 @@ private static bool TryValidateDataRoot(string candidate, out string normalized, File.WriteAllText(probe, "ok", new UTF8Encoding(false)); using (FileStream stream = new FileStream(probe, FileMode.Open, FileAccess.Read, FileShare.Read)) { - if (stream.Length != 2) throw new IOException("쓰기 확인 파일을 읽지 못했습니다."); + if (stream.Length != 2) throw Localized(new IOException("쓰기 확인 파일을 읽지 못했습니다."), "Could not read the write-verification file."); } } finally diff --git a/WindowsNotifications.cs b/WindowsNotifications.cs index ec53784..ffcada0 100644 --- a/WindowsNotifications.cs +++ b/WindowsNotifications.cs @@ -62,7 +62,7 @@ private static WindowsNotificationSettings ReadWindowsNotificationSettings() if (!File.Exists(path)) return new WindowsNotificationSettings(); FileInfo info = new FileInfo(path); if (info.Length <= 0 || info.Length > WindowsNotificationSettingsMaximumBytes) - throw new InvalidDataException("Windows 알림 설정 파일 크기가 올바르지 않습니다."); + throw Localized(new InvalidDataException("Windows 알림 설정 파일 크기가 올바르지 않습니다."), "The Windows notification settings file size is invalid."); WindowsNotificationSettings settings; try { @@ -70,7 +70,7 @@ private static WindowsNotificationSettings ReadWindowsNotificationSettings() } catch (Exception exception) { - throw new InvalidDataException("Windows 알림 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception); + throw Localized(new InvalidDataException("Windows 알림 설정 파일이 손상되었습니다. 원본 파일은 변경하지 않았습니다.", exception), "The Windows notification settings file is damaged. The original file was left unchanged."); } ValidateWindowsNotificationSettings(settings); return settings; @@ -96,14 +96,14 @@ private static void WriteWindowsNotificationSettings(WindowsNotificationSettings private static void ValidateWindowsNotificationSettings(WindowsNotificationSettings settings) { if (settings == null || settings.SchemaVersion != WindowsNotificationSettingsSchemaVersion) - throw new InvalidDataException("지원하지 않는 Windows 알림 설정 버전입니다."); + throw Localized(new InvalidDataException("지원하지 않는 Windows 알림 설정 버전입니다."), "Unsupported Windows notification settings version."); if (!string.Equals(settings.MinimumSeverity, "info", StringComparison.Ordinal) && !string.Equals(settings.MinimumSeverity, "warning", StringComparison.Ordinal) && !string.Equals(settings.MinimumSeverity, "error", StringComparison.Ordinal)) - throw new InvalidDataException("Windows 알림 최소 중요도가 올바르지 않습니다."); + throw Localized(new InvalidDataException("Windows 알림 최소 중요도가 올바르지 않습니다."), "The minimum Windows notification severity is invalid."); if (settings.QuietStartMinutes < 0 || settings.QuietStartMinutes >= 24 * 60 || settings.QuietEndMinutes < 0 || settings.QuietEndMinutes >= 24 * 60) - throw new InvalidDataException("Windows 알림 조용한 시간 범위가 올바르지 않습니다."); + throw Localized(new InvalidDataException("Windows 알림 조용한 시간 범위가 올바르지 않습니다."), "The Windows notification quiet-hours range is invalid."); } private static T WithWindowsNotificationSettingsLock(Func action) @@ -119,7 +119,7 @@ private static T WithWindowsNotificationSettingsLock(Func action) { try { entered = mutex.WaitOne(TimeSpan.FromSeconds(5)); } catch (AbandonedMutexException) { entered = true; } - if (!entered) throw new IOException("다른 MineHarbor 프로세스가 Windows 알림 설정을 갱신하고 있습니다."); + if (!entered) throw Localized(new IOException("다른 MineHarbor 프로세스가 Windows 알림 설정을 갱신하고 있습니다."), "Another MineHarbor process is updating the Windows notification settings."); return action(); } finally @@ -548,7 +548,7 @@ private bool SaveSettings(bool closeAfterSave) } catch (Exception exception) { - ShowMineHarborDialog(this, (IsManagedKorean() ? "알림 설정을 저장하지 못했습니다: " : "Could not save notification settings: ") + exception.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + ShowMineHarborDialog(this, (IsManagedKorean() ? "알림 설정을 저장하지 못했습니다: " : "Could not save notification settings: ") + DescribeException(exception), Text, MessageBoxButtons.OK, MessageBoxIcon.Error); return false; } } diff --git a/decompiled/Launcher.decompiled.cs b/decompiled/Launcher.decompiled.cs index b549cef..d8a825f 100644 --- a/decompiled/Launcher.decompiled.cs +++ b/decompiled/Launcher.decompiled.cs @@ -342,7 +342,7 @@ private static int Main(string[] args) } catch (Exception ex) { - ShowLauncherMessage("런처에서 처리하지 못한 오류가 발생했습니다.\r\n\r\n" + ex.Message, true); + ShowLauncherMessage("런처에서 처리하지 못한 오류가 발생했습니다.\r\n\r\n" + DescribeException(ex), true); return 1; } finally diff --git a/docs/ai/SYNC_STATE.md b/docs/ai/SYNC_STATE.md index b3d1fe5..d986003 100644 --- a/docs/ai/SYNC_STATE.md +++ b/docs/ai/SYNC_STATE.md @@ -1,5 +1,16 @@ # AI Agent Synchronization State +## Claude Exception Message Localization - 2026-07-27 + +- **Current Version**: 1.18.0 (build 26.2.45.86) +- **Branch**: `claude/remote-control-u4tvpt` (v1.17.0 병합 후 main에서 다시 시작) +- **Status**: 영어 UI에 한국어 예외 메시지가 노출되던 문제의 기반 작업과 1차 적용을 마치고 v1.18.0 릴리스 진행 +- 예외 **타입은 바꾸지 않았습니다.** 코드에 `catch (InvalidDataException)`처럼 타입에 의존하는 곳이 20군데 있어, 전용 예외 타입을 도입하면 그 흐름이 모두 바뀝니다. 대신 `Localized(new XException("한국어"), "English")`로 `Exception.Data`에 영어 문구만 덧붙입니다. +- `DescribeException(Exception)`이 현재 언어에 맞는 문구를 고릅니다. 영어 문구가 없으면 지금까지와 동일하게 `exception.Message`를 그대로 반환하므로, 변환하지 않은 예외도 안전합니다. +- 표시 경로 30곳(`ShowMineHarborDialog`, `ShowLauncherMessage`, 상태 레이블, 콘텐츠 작업 표시줄)을 `DescribeException`으로 바꿨습니다. +- throw 지점 121곳을 변환했습니다: `OperationsHistory`(16), `ManagedServerHandoff`(12), `StorageConfiguration`(2), `WindowsNotifications`(6), `ServerTrash`(6), `BackgroundAgent`(16), `ServerAutomation`(22), `DiscordRemoteManagement`(41). +- **아직 남은 throw 지점 약 266곳**: `decompiled/Launcher.decompiled.cs`(67), `ContentManagementServices`(60), `RuntimeCompatibility`(45), `BackupAndProfileTools`(30), `ContentAndDiagnostics`(21), `DuplicationSettings`(16), `QuickCommandsAndBridge`(12), `UpnpExternalAccess`(6), `ServerManagementFeatures`(5), 그 외. 이들은 영어 문구가 없으므로 기존과 동일하게 한국어로 표시됩니다. +- 다음 담당자는 같은 방식(`Localized(new ...)`)으로 남은 파일을 이어서 변환하면 됩니다. 회귀 테스트 `TestLocalizedExceptionMessages`가 타입 보존과 언어별 선택을 검증합니다. ## Claude Discord Feature Rollout - 2026-07-26 - **Current Version**: 1.17.0 (build 26.2.45.85) diff --git a/tests/Launcher.Tests.cs b/tests/Launcher.Tests.cs index 754738a..74641d2 100644 --- a/tests/Launcher.Tests.cs +++ b/tests/Launcher.Tests.cs @@ -68,6 +68,7 @@ private static int Main(string[] args) TestDiscordRemoteManagement(temporary); TestOperationsHistory(temporary); TestDiagnosticRedaction(temporary); + TestLocalizedExceptionMessages(); TestQuickCommandsAndBridge(temporary); Console.WriteLine("PASSED=" + passed); return 0; @@ -2595,6 +2596,42 @@ private static void TestOperationsHistory(string root) Pass(); } + private static void TestLocalizedExceptionMessages() + { + Type localizationType = launcher.GetNestedType("Localization", BindingFlags.NonPublic); + FieldInfo languageField = localizationType.GetField("CurrentLanguage", BindingFlags.Static | BindingFlags.Public); + object original = languageField.GetValue(null); + try + { + // 예외 타입은 그대로 두고 Data에만 영어 문구를 붙여, 기존 catch 절이 영향을 받지 않아야 합니다. + Exception thrown = null; + try { Invoke("ValidateScheduledCommand", new object[] { string.Empty }); } + catch (TargetInvocationException reflection) { thrown = reflection.InnerException; } + if (thrown == null) throw new InvalidOperationException("예약 명령 검증이 예외를 던지지 않았습니다."); + if (!(thrown is InvalidDataException)) + throw new InvalidOperationException("예외 타입이 InvalidDataException에서 바뀌었습니다: " + thrown.GetType().Name); + + languageField.SetValue(null, "ko"); + string korean = Convert.ToString(Invoke("DescribeException", new object[] { thrown })); + Equal(thrown.Message, korean, "한국어 UI에서는 기존 메시지 유지"); + + languageField.SetValue(null, "en"); + string english = Convert.ToString(Invoke("DescribeException", new object[] { thrown })); + if (english.IndexOf("scheduled command", StringComparison.OrdinalIgnoreCase) < 0) + throw new InvalidOperationException("영어 UI에서 영어 예외 문구가 사용되지 않았습니다: " + english); + foreach (char character in english) + if (character >= 0xAC00 && character <= 0xD7A3) + throw new InvalidOperationException("영어 예외 문구에 한글이 남아 있습니다: " + english); + + // 영어 문구가 없는 예외는 지금까지와 동일하게 원래 메시지를 사용해야 합니다. + Exception plain = new InvalidOperationException("설명이 없는 예외"); + Equal("설명이 없는 예외", Convert.ToString(Invoke("DescribeException", new object[] { plain })), "영어 문구 없는 예외는 원래 메시지 사용"); + Equal(string.Empty, Convert.ToString(Invoke("DescribeException", new object[] { null })), "null 예외는 빈 문자열"); + } + finally { languageField.SetValue(null, original); } + Pass(); + } + private static void TestDiagnosticRedaction(string root) { string server = Path.Combine(root, "server"); diff --git a/version.json b/version.json index 4e6fc66..8808677 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "productVersion": "1.17.0", - "buildNumber": "26.2.45.85", + "productVersion": "1.18.0", + "buildNumber": "26.2.45.86", "minimumSupportedVersion": "0.1.0" }