From 097add939784ba418956d3f60dd44fb30105c0e5 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 11 Nov 2026 02:44:34 +0800 Subject: [PATCH] [feat] add feedback dialog and diagnostic log export, refs #149 --- .../src/gui/application/gui_application.cpp | 102 ++++++++++ .../src/gui/application/gui_application.h | 2 + .../assets/localization/localization_data.h | 13 ++ .../features/feedback/diagnostic_export.cpp | 183 ++++++++++++++++++ .../gui/features/feedback/diagnostic_export.h | 32 +++ apps/desktop/src/gui/ui/about_panel.slint | 7 +- apps/desktop/src/gui/ui/crossdesk_ui.slint | 1 + apps/desktop/src/gui/ui/feedback_panel.slint | 99 ++++++++++ apps/desktop/src/gui/ui/main_window.slint | 75 ++++++- apps/desktop/src/gui/ui/ui_localization.h | 9 + .../src/platform/macos/gui/window_drag_mac.mm | 41 +++- apps/desktop/src/platform/window_drag.h | 3 + apps/desktop/xmake/targets.lua | 3 +- localization/catalog.json | 171 +++++++++++++++- 14 files changed, 731 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/gui/features/feedback/diagnostic_export.cpp create mode 100644 apps/desktop/src/gui/features/feedback/diagnostic_export.h create mode 100644 apps/desktop/src/gui/ui/feedback_panel.slint diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index a3d9f3e9..a51d6ad5 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +39,7 @@ #include "crossdesk_ui.h" #include "fa_solid_900.h" #include "features/about/about_catalog.h" +#include "features/feedback/diagnostic_export.h" #include "features/file_transfer/transfer_notification_layout.h" #include "features/file_transfer/transfer_notification_state.h" #include "features/file_transfer/file_browser_window.h" @@ -1106,6 +1109,8 @@ struct GuiApplication::SlintUi { slint::Timer server_file_dialog_timer; std::shared_ptr file_dialog_lifetime = std::make_shared(0); bool file_dialog_open = false; + std::future diagnostic_export; + std::string diagnostic_saved_path; WindowDragState main_drag, gallery_drag; WindowDragState stream_drag; WindowDragState server_drag; @@ -2054,6 +2059,14 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { if (link.starts_with("https://")) OpenUrl(link); }); main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); }); + main->on_open_feedback_github([this, main] { + if (!OpenUrl("https://github.com/kunkundi/crossdesk/issues/new/choose")) { + main->set_feedback_succeeded(false); + main->set_feedback_status(UiText(localization::Literal( + "无法打开浏览器,请访问 GitHub 项目页面。", localization_language_index_))); + } + }); + main->on_export_diagnostic_logs([this] { ExportDiagnosticLogs(); }); main->on_open_afdian_sponsor([this] { OpenUrl("https://afdian.com/a/kunkundi"); }); @@ -2525,6 +2538,7 @@ void GuiApplication::Tick() { if (ShutdownDrained()) slint::quit_event_loop(); return; } + PollDiagnosticExport(); #ifdef _WIN32 TickLocalHost(); #endif @@ -5543,6 +5557,94 @@ void GuiApplication::OpenFileDialog(const std::string& title, #endif } +void GuiApplication::ExportDiagnosticLogs() { + if (!ui_ || exit_ || ui_->file_dialog_open || ui_->diagnostic_export.valid()) return; + ui_->file_dialog_open = true; + for (const auto& window : {ui_->main, ui_->wall}) { + window->set_feedback_exporting(true); + window->set_feedback_status(""); + window->set_feedback_saved_path(""); + window->set_feedback_succeeded(false); + } + const auto filename = feedback::DiagnosticFileName(); + const auto title = localization::Literal("导出诊断日志", localization_language_index_); + std::weak_ptr lifetime = ui_->file_dialog_lifetime; + auto finished = [this, lifetime](std::string path) { + if (lifetime.expired()) return; + ui_->file_dialog_open = false; + if (exit_) return; + if (path.empty()) { + for (const auto& window : {ui_->main, ui_->wall}) window->set_feedback_exporting(false); + return; + } + try { + auto destination = PathFromUtf8(path); + auto extension = destination.extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (extension != ".zip") { + destination += ".zip"; + path += ".zip"; + // The save dialog only confirms replacement of the original path. + if (std::filesystem::exists(destination)) throw std::runtime_error("Destination exists"); + } +#ifdef __OHOS__ + // The folder picker cannot confirm replacement of an existing archive. + if (std::filesystem::exists(destination)) throw std::runtime_error("Destination exists"); +#endif + ui_->diagnostic_saved_path = path; + get_logger()->flush(); + ui_->diagnostic_export = std::async(std::launch::async, + [logs = path_manager_->GetLogPath(), destination] { + return feedback::ExportDiagnostics(logs, logs, destination, CROSSDESK_VERSION); + }); + } catch (...) { + for (const auto& window : {ui_->main, ui_->wall}) { + window->set_feedback_exporting(false); + window->set_feedback_status(UiText(localization::Literal( + "无法保存诊断包,请选择其他位置重试。", localization_language_index_))); + } + } + }; +#if defined(__APPLE__) + SaveNativeFileDialog(title, filename, std::move(finished)); +#elif defined(__OHOS__) + const char* folder = tinyfd_selectFolderDialog(title.c_str(), ""); + finished(folder ? std::string(folder) + "/" + filename : ""); +#else + const char* filters[] = {"*.zip"}; + const char* path = tinyfd_saveFileDialog(title.c_str(), filename.c_str(), 1, + filters, "ZIP archive"); + finished(path ? path : ""); +#endif +} + +void GuiApplication::PollDiagnosticExport() { + if (!ui_->diagnostic_export.valid() || + ui_->diagnostic_export.wait_for(0ms) != std::future_status::ready) return; + feedback::ExportResult result{feedback::ExportError::write_failed}; + try { result = ui_->diagnostic_export.get(); } catch (...) {} + const char* message = "无法保存诊断包,请选择其他位置重试。"; + switch (result.error) { + case feedback::ExportError::none: + message = "诊断日志已导出,请将 ZIP 附到 GitHub Issue。"; break; + case feedback::ExportError::no_logs: + message = "没有找到可导出的日志。"; break; + case feedback::ExportError::read_failed: + message = "无法读取日志,请稍后重试。"; break; + case feedback::ExportError::too_large: + message = "日志文件过大,无法导出。"; break; + case feedback::ExportError::write_failed: break; + } + const bool succeeded = result.error == feedback::ExportError::none; + for (const auto& window : {ui_->main, ui_->wall}) { + window->set_feedback_exporting(false); + window->set_feedback_succeeded(succeeded); + window->set_feedback_status(UiText(localization::Literal(message, localization_language_index_))); + window->set_feedback_saved_path(UiText(succeeded ? ui_->diagnostic_saved_path : "")); + } +} + bool GuiApplication::OpenUrl(const std::string& url) { return SDL_OpenURL(url.c_str()); } diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index 8f3bdab0..065764e5 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -62,6 +62,8 @@ private: void TickLocalHost(); #endif void PollUpdateCheck(); + void ExportDiagnosticLogs(); + void PollDiagnosticExport(); void ShareLocalCursorState(); bool RequestPasswordChange(const std::string &password); void HandlePasswordChangeResult(); diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 78a9519e..e0962ff8 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -542,6 +542,19 @@ inline constexpr TranslationRow kTranslationRows[] = { {"开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", {"开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", "Boot startup requires the installed Release build. Portable and Debug builds do not support the unattended service.", "Автозапуск требует установленной сборки Release. Переносная версия и Debug не поддерживают автономную службу.", "開機自啟需要安裝版 Release;可攜版和 Debug 不支援無人值守服務。", "自動起動にはインストール版 Release が必要です。ポータブル版と Debug は無人アクセスサービスに対応していません。", "자동 시작에는 설치형 Release 버전이 필요합니다. 포터블 및 Debug 버전은 무인 접속 서비스를 지원하지 않습니다.", "Der Systemstart erfordert die installierte Release-Version. Portable- und Debug-Versionen unterstützen den unbeaufsichtigten Dienst nicht.", "Le démarrage automatique nécessite la version Release installée. Les versions portable et Debug ne prennent pas en charge le service sans surveillance.", "El inicio automático requiere la versión Release instalada. Las versiones portátil y Debug no admiten el servicio desatendido.", "A inicialização automática exige a versão Release instalada. As versões portátil e Debug não oferecem suporte ao serviço não assistido."}}, {"无法配置无人值守服务,请检查管理员权限和安装文件。", {"无法配置无人值守服务,请检查管理员权限和安装文件。", "Could not configure the unattended service. Check administrator privileges and installation files.", "Не удалось настроить автономную службу. Проверьте права администратора и файлы установки.", "無法設定無人值守服務,請檢查管理員權限和安裝檔案。", "無人アクセスサービスを設定できません。管理者権限とインストールファイルを確認してください。", "무인 접속 서비스를 설정할 수 없습니다. 관리자 권한과 설치 파일을 확인하세요.", "Der unbeaufsichtigte Dienst konnte nicht eingerichtet werden. Prüfen Sie Administratorrechte und Installationsdateien.", "Impossible de configurer le service sans surveillance. Vérifiez les droits administrateur et les fichiers d’installation.", "No se pudo configurar el servicio desatendido. Compruebe los permisos de administrador y los archivos de instalación.", "Não foi possível configurar o serviço não assistido. Verifique as permissões de administrador e os arquivos de instalação."}}, {"开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", {"开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", "Connect after boot without signing in. After login, keep the same ID, password and full functionality.", "Подключайтесь после загрузки без входа в Windows. После входа сохраняются ID, пароль и все функции.", "開機後無需登入即可連線;登入後繼續使用同一 ID、密碼和完整功能。", "サインイン前でも接続できます。サインイン後も同じ ID とパスワードですべての機能を利用できます。", "부팅 후 로그인하지 않아도 연결할 수 있습니다. 로그인 후에도 같은 ID와 비밀번호로 모든 기능을 사용할 수 있습니다.", "Verbinden Sie sich nach dem Start ohne Anmeldung. Nach der Anmeldung bleiben ID, Passwort und alle Funktionen erhalten.", "Connectez-vous après le démarrage sans ouvrir de session. Après connexion, conservez les mêmes ID et mot de passe et toutes les fonctions.", "Conéctese tras arrancar sin iniciar sesión. Después de iniciar sesión, conserve el mismo ID, contraseña y todas las funciones.", "Conecte-se após a inicialização sem fazer login. Após o login, mantenha o mesmo ID, senha e todas as funções."}}, + {"前往 GitHub 反馈", {"前往 GitHub 反馈", "Give feedback on GitHub", "Оставить отзыв на GitHub", "前往 GitHub 回饋", "GitHub でフィードバック", "GitHub에 피드백 보내기", "Feedback auf GitHub geben", "Donner un avis sur GitHub", "Enviar comentarios en GitHub", "Enviar comentários no GitHub"}}, + {"反馈与建议", {"反馈与建议", "Feedback & suggestions", "Отзывы и предложения", "回饋與建議", "フィードバック・ご提案", "피드백 및 제안", "Feedback und Vorschläge", "Avis et suggestions", "Comentarios y sugerencias", "Comentários e sugestões"}}, + {"导出诊断日志", {"导出诊断日志", "Export diagnostic logs", "Экспорт журналов диагностики", "匯出診斷日誌", "診断ログをエクスポート", "진단 로그 내보내기", "Diagnoseprotokolle exportieren", "Exporter les journaux de diagnostic", "Exportar registros de diagnóstico", "Exportar logs de diagnóstico"}}, + {"将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。", {"将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。", "Save CrossDesk and MiniRTC logs with basic version information as a ZIP file.", "Сохраните журналы CrossDesk и MiniRTC и сведения о версии в ZIP-файл.", "將 CrossDesk、MiniRTC 日誌和基本版本資訊儲存為 ZIP 檔案。", "CrossDesk・MiniRTC のログと基本的なバージョン情報を ZIP ファイルに保存します。", "CrossDesk 및 MiniRTC 로그와 기본 버전 정보를 ZIP 파일로 저장합니다.", "Speichern Sie CrossDesk- und MiniRTC-Protokolle mit Versionsinformationen als ZIP-Datei.", "Enregistrez les journaux CrossDesk et MiniRTC et les informations de version dans un fichier ZIP.", "Guarda los registros de CrossDesk y MiniRTC con la información de versión en un archivo ZIP.", "Salve os logs do CrossDesk e do MiniRTC com informações de versão em um arquivo ZIP."}}, + {"报告问题或提出功能建议。", {"报告问题或提出功能建议。", "Report a problem or suggest an improvement.", "Сообщите о проблеме или предложите улучшение.", "回報問題或提出功能建議。", "問題の報告や機能の改善をご提案ください。", "문제를 보고하거나 개선 사항을 제안하세요.", "Melden Sie ein Problem oder schlagen Sie eine Verbesserung vor.", "Signalez un problème ou proposez une amélioration.", "Informa de un problema o sugiere una mejora.", "Relate um problema ou sugira uma melhoria."}}, + {"无法保存诊断包,请选择其他位置重试。", {"无法保存诊断包,请选择其他位置重试。", "Could not save the diagnostic archive. Choose another location and try again.", "Не удалось сохранить архив диагностики. Выберите другое расположение и повторите попытку.", "無法儲存診斷包,請選擇其他位置重試。", "診断アーカイブを保存できません。別の場所を選んで再度お試しください。", "진단 파일을 저장할 수 없습니다. 다른 위치를 선택하고 다시 시도하세요.", "Das Diagnosearchiv konnte nicht gespeichert werden. Wählen Sie einen anderen Speicherort und versuchen Sie es erneut.", "Impossible de sauvegarder l’archive de diagnostic. Choisissez un autre emplacement et réessayez.", "No se pudo guardar el archivo de diagnóstico. Elige otra ubicación e inténtalo de nuevo.", "Não foi possível salvar o arquivo de diagnóstico. Escolha outro local e tente novamente."}}, + {"无法打开浏览器,请访问 GitHub 项目页面。", {"无法打开浏览器,请访问 GitHub 项目页面。", "Could not open the browser. Please visit the project on GitHub.", "Не удалось открыть браузер. Откройте страницу проекта на GitHub.", "無法開啟瀏覽器,請前往 GitHub 專案頁面。", "ブラウザーを開けません。GitHub のプロジェクトページにアクセスしてください。", "브라우저를 열 수 없습니다. GitHub 프로젝트 페이지를 방문하세요.", "Der Browser konnte nicht geöffnet werden. Besuchen Sie die Projektseite auf GitHub.", "Impossible d’ouvrir le navigateur. Consultez la page du projet sur GitHub.", "No se pudo abrir el navegador. Visita la página del proyecto en GitHub.", "Não foi possível abrir o navegador. Acesse a página do projeto no GitHub."}}, + {"无法读取日志,请稍后重试。", {"无法读取日志,请稍后重试。", "Could not read the logs. Please try again later.", "Не удалось прочитать журналы. Повторите попытку позже.", "無法讀取日誌,請稍後重試。", "ログを読み取れません。後でもう一度お試しください。", "로그를 읽을 수 없습니다. 나중에 다시 시도하세요.", "Die Protokolle konnten nicht gelesen werden. Versuchen Sie es später erneut.", "Impossible de lire les journaux. Réessayez plus tard.", "No se pudieron leer los registros. Inténtalo de nuevo más tarde.", "Não foi possível ler os logs. Tente novamente mais tarde."}}, + {"日志文件过大,无法导出。", {"日志文件过大,无法导出。", "The log file is too large to export.", "Файл журнала слишком велик для экспорта.", "日誌檔案過大,無法匯出。", "ログファイルが大きすぎるためエクスポートできません。", "로그 파일이 너무 커서 내보낼 수 없습니다.", "Die Protokolldatei ist zu groß für den Export.", "Le fichier journal est trop volumineux pour être exporté.", "El archivo de registro es demasiado grande para exportarlo.", "O arquivo de log é grande demais para exportar."}}, + {"正在导出…", {"正在导出…", "Exporting…", "Экспорт…", "正在匯出…", "エクスポート中…", "내보내는 중…", "Export wird erstellt…", "Exportation…", "Exportando…", "Exportando…"}}, + {"没有找到可导出的日志。", {"没有找到可导出的日志。", "No logs were found to export.", "Журналы для экспорта не найдены.", "找不到可匯出的日誌。", "エクスポートできるログが見つかりません。", "내보낼 로그를 찾을 수 없습니다.", "Keine Protokolle zum Exportieren gefunden.", "Aucun journal à exporter trouvé.", "No se encontraron registros para exportar.", "Nenhum log encontrado para exportar."}}, + {"诊断日志已导出,请将 ZIP 附到 GitHub Issue。", {"诊断日志已导出,请将 ZIP 附到 GitHub Issue。", "Diagnostic logs exported. Attach the ZIP to your GitHub issue.", "Журналы диагностики экспортированы. Прикрепите ZIP-файл к обращению на GitHub.", "診斷日誌已匯出,請將 ZIP 附到 GitHub Issue。", "診断ログをエクスポートしました。ZIP を GitHub Issue に添付してください。", "진단 로그를 내보냈습니다. GitHub 이슈에 ZIP을 첨부하세요.", "Diagnoseprotokolle exportiert. Hängen Sie die ZIP-Datei an Ihr GitHub-Issue an.", "Journaux de diagnostic exportés. Joignez le ZIP à votre ticket GitHub.", "Registros de diagnóstico exportados. Adjunta el ZIP a tu issue de GitHub.", "Logs de diagnóstico exportados. Anexe o ZIP à sua issue do GitHub."}}, + {"请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。", {"请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。", "Attach the exported ZIP log archive to your GitHub issue to help us investigate.", "Прикрепите экспортированный ZIP-архив журналов к обращению на GitHub, чтобы помочь нам разобраться в проблеме.", "請在 GitHub Issue 中附上匯出的 ZIP 日誌封存檔,便於排查問題。", "問題の調査のため、エクスポートしたログの ZIP ファイルを GitHub Issue に添付してください。", "문제 조사에 도움이 되도록 내보낸 ZIP 로그 파일을 GitHub 이슈에 첨부해 주세요.", "Hängen Sie das exportierte ZIP-Archiv mit den Protokollen an Ihr GitHub-Issue an, damit wir das Problem untersuchen können.", "Joignez l’archive ZIP des journaux exportés à votre ticket GitHub pour nous aider à analyser le problème.", "Adjunta el archivo ZIP de los registros exportados a tu issue de GitHub para ayudarnos a investigar el problema.", "Anexe o arquivo ZIP dos logs exportados à sua issue do GitHub para nos ajudar a investigar o problema."}}, }; struct NamedTranslation { const char* name; const char* literal; }; diff --git a/apps/desktop/src/gui/features/feedback/diagnostic_export.cpp b/apps/desktop/src/gui/features/feedback/diagnostic_export.cpp new file mode 100644 index 00000000..c9aa8976 --- /dev/null +++ b/apps/desktop/src/gui/features/feedback/diagnostic_export.cpp @@ -0,0 +1,183 @@ +#include "diagnostic_export.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/private_file.h" + +namespace crossdesk::feedback { +namespace { +constexpr size_t kMaxSource = 8 * 1024 * 1024; + +std::string TimeText(std::time_t value, const char* format, bool utc = false) { + std::tm time{}; +#ifdef _WIN32 + if (utc) + gmtime_s(&time, &value); + else + localtime_s(&time, &value); +#else + if (utc) + gmtime_r(&value, &time); + else + localtime_r(&value, &time); +#endif + std::ostringstream out; + out << std::put_time(&time, format); + return out.str(); +} + +struct Entry { + std::string name, text; + uint32_t crc = 0, offset = 0; +}; + +void Put(std::string& out, uint32_t value, int bytes) { + for (int i = 0; i < bytes; ++i) out += static_cast(value >> (i * 8)); +} +uint32_t Crc32(std::string_view text) { + static const auto table = [] { + std::array values{}; + for (uint32_t i = 0; i < values.size(); ++i) { + uint32_t value = i; + for (int bit = 0; bit < 8; ++bit) + value = (value >> 1) ^ ((value & 1) ? 0xedb88320u : 0u); + values[i] = value; + } + return values; + }(); + uint32_t crc = 0xffffffffu; + for (unsigned char byte : text) crc = table[(crc ^ byte) & 255] ^ (crc >> 8); + return crc ^ 0xffffffffu; +} + +// A bounded, stored ZIP needs neither ZIP64 nor an external archiver. Fixed +// ASCII entry names prevent exported paths from disclosing local directories. +std::string Zip(std::vector& entries) { + std::string out; + for (auto& entry : entries) { + entry.offset = static_cast(out.size()); + entry.crc = Crc32(entry.text); + Put(out, 0x04034b50, 4); + Put(out, 20, 2); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, 33, 2); + Put(out, entry.crc, 4); + Put(out, entry.text.size(), 4); + Put(out, entry.text.size(), 4); + Put(out, entry.name.size(), 2); + Put(out, 0, 2); + out += entry.name; + out += entry.text; + } + const auto start = out.size(); + for (const auto& entry : entries) { + Put(out, 0x02014b50, 4); + Put(out, 20, 2); + Put(out, 20, 2); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, 33, 2); + Put(out, entry.crc, 4); + Put(out, entry.text.size(), 4); + Put(out, entry.text.size(), 4); + Put(out, entry.name.size(), 2); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, 0, 4); + Put(out, entry.offset, 4); + out += entry.name; + } + const auto size = out.size() - start; + Put(out, 0x06054b50, 4); + Put(out, 0, 2); + Put(out, 0, 2); + Put(out, entries.size(), 2); + Put(out, entries.size(), 2); + Put(out, size, 4); + Put(out, start, 4); + Put(out, 0, 2); + return out; +} + +std::string Platform() { +#if defined(_WIN32) + return "Windows"; +#elif defined(__APPLE__) + return "macOS"; +#elif defined(__OHOS__) + return "HarmonyOS"; +#else + return "Linux"; +#endif +} +} // namespace + +std::string DiagnosticFileName() { + return "CrossDesk-diagnostics-" + + TimeText(std::time(nullptr), "%Y%m%d-%H%M%S") + ".zip"; +} + +ExportResult ExportDiagnostics(const std::filesystem::path& application_logs, + const std::filesystem::path& transport_logs, + const std::filesystem::path& destination, + const std::string& version, std::time_t now) { + std::vector entries; + try { + for (const auto& [directory, name] : + {std::pair{application_logs, std::string("crossdesk.log")}, + std::pair{transport_logs, std::string("minirtc.log")}}) { + const auto path = directory / name; + std::error_code error; + const auto status = std::filesystem::symlink_status(path, error); + if (error == std::errc::no_such_file_or_directory || + (!error && status.type() == std::filesystem::file_type::not_found)) + continue; + if (error || !std::filesystem::is_regular_file(status)) + return {ExportError::read_failed}; + std::ifstream input(path, std::ios::binary | std::ios::ate); + if (!input || input.tellg() < 0) return {ExportError::read_failed}; + const auto size = static_cast(input.tellg()); + // Both rotating loggers normally cap each active file at 5 MiB. Keep + // the complete archive below GitHub's attachment limit without silently + // truncating an unexpectedly large log. + if (size > kMaxSource) return {ExportError::too_large}; + input.seekg(0); + std::string data(size, '\0'); + input.read(data.data(), static_cast(size)); + if (input.bad() || static_cast(input.gcount()) != size) + return {ExportError::read_failed}; + if (!data.empty()) entries.push_back({name, std::move(data)}); + } + } catch (...) { + return {ExportError::read_failed}; + } + if (entries.empty()) return {ExportError::no_logs}; + std::string info = + "CrossDesk diagnostics\nVersion: " + version + + "\nPlatform: " + Platform() + + "\nProcess bits: " + std::to_string(sizeof(void*) * 8) + + "\nExported UTC: " + TimeText(now, "%Y-%m-%d %H:%M:%S", true) + + "\nActive log files are included as-is, without filtering or redaction.\n" + "Configuration files and screen previews are not included.\n" + "Review the logs before sharing. GitHub issue attachments are public.\n"; + entries.insert(entries.begin(), {"environment.txt", std::move(info)}); + try { + private_file::Write(destination, Zip(entries)); + } catch (...) { + return {ExportError::write_failed}; + } + return {}; +} +} // namespace crossdesk::feedback diff --git a/apps/desktop/src/gui/features/feedback/diagnostic_export.h b/apps/desktop/src/gui/features/feedback/diagnostic_export.h new file mode 100644 index 00000000..4a952206 --- /dev/null +++ b/apps/desktop/src/gui/features/feedback/diagnostic_export.h @@ -0,0 +1,32 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-11-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _DIAGNOSTIC_EXPORT_H_ +#define _DIAGNOSTIC_EXPORT_H_ + +#include +#include +#include + +namespace crossdesk::feedback { + +enum class ExportError { none, no_logs, read_failed, write_failed, too_large }; +struct ExportResult { + ExportError error = ExportError::none; +}; + +// Exports byte-for-byte snapshots of the active application and transport +// logs, plus basic environment information. No account configuration is read. +ExportResult ExportDiagnostics(const std::filesystem::path& application_logs, + const std::filesystem::path& transport_logs, + const std::filesystem::path& destination, + const std::string& version, + std::time_t now = std::time(nullptr)); +std::string DiagnosticFileName(); + +} // namespace crossdesk::feedback + +#endif diff --git a/apps/desktop/src/gui/ui/about_panel.slint b/apps/desktop/src/gui/ui/about_panel.slint index 7002121c..de66f922 100644 --- a/apps/desktop/src/gui/ui/about_panel.slint +++ b/apps/desktop/src/gui/ui/about_panel.slint @@ -55,6 +55,7 @@ export component AboutNavigationRow inherits FlatSurface { in property detail; in property grouped: false; in property external: false; + in property enabled: true; callback clicked; min-width: 0px; min-height: max(44px, labels.preferred-height + 20px); @@ -62,10 +63,12 @@ export component AboutNavigationRow inherits FlatSurface { : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface; border-radius: root.grouped ? 0px : UiRadius.control; outline-enabled: !root.grouped; outline-color: UiSurface.edge; + opacity: root.enabled ? 1 : 0.45; accessible-role: button; accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "") + (root.detail != "" ? ", " + root.detail : ""); - accessible-action-default => { root.clicked(); } + accessible-enabled: root.enabled; + accessible-action-default => { if root.enabled { root.clicked(); } } if root.icon != "": Rectangle { x: 12px; y: (parent.height - self.height) / 2; width: 28px; height: 28px; @@ -103,7 +106,7 @@ export component AboutNavigationRow inherits FlatSurface { font-size: 11px; vertical-alignment: center; horizontal-alignment: center; accessible-role: none; } - touch := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } } + touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } } } export component AboutPanel inherits SettingsDialog { diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index 04ba0bf3..6b12e0ee 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -1,6 +1,7 @@ // Single AOT entry point. Generating one header for the entire UI avoids // duplicate imported-component definitions when C++ uses all three windows. export { AnnouncementItem, MessageStrings } from "message_panel.slint"; +export { FeedbackStrings } from "feedback_panel.slint"; export { AboutComponent, AboutDocument, AboutPage } from "about_panel.slint"; export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; diff --git a/apps/desktop/src/gui/ui/feedback_panel.slint b/apps/desktop/src/gui/ui/feedback_panel.slint new file mode 100644 index 00000000..a8ca419d --- /dev/null +++ b/apps/desktop/src/gui/ui/feedback_panel.slint @@ -0,0 +1,99 @@ +import { ScrollView } from "std-widgets.slint"; +import { SettingsDialog, SettingsButton } from "settings_controls.slint"; +import { AboutNavigationGroup, AboutNavigationRow, AboutRowSeparator } from "about_panel.slint"; +import { SurfaceSeparator, UiSurface } from "ui_surface.slint"; +import { UiRadius } from "ui_radius.slint"; +import { WorkbenchStyle } from "workbench_controls.slint"; + +export global FeedbackStrings { + in-out property title: "Feedback & suggestions"; + in-out property github: "Give feedback on GitHub"; + in-out property github-hint: "Report a problem or suggest an improvement."; + in-out property export: "Export diagnostic logs"; + in-out property export-hint: "Save CrossDesk and MiniRTC logs with basic version information as a ZIP file."; + in-out property exporting: "Exporting…"; + in-out property attachment-hint: "Attach the exported ZIP log archive to your GitHub issue to help us investigate."; + in-out property close: "Close"; +} + +export component FeedbackPanel inherits SettingsDialog { + in property exporting: false; + in property status; + in property succeeded: false; + in property saved-path; + out property preferred-dialog-height: root.content-top + + content.preferred-height + 16px + footer.preferred-height + 24px; + callback open-github; + callback export-logs; + title: FeedbackStrings.title; + close-label: FeedbackStrings.close; + ScrollView { + x: 24px; y: root.content-top; + width: parent.width - 48px + 22px; + height: footer.y - self.y - 16px; + viewport-width: self.visible-width; + viewport-height: content.preferred-height; + horizontal-scrollbar-policy: always-off; + vertical-scrollbar-policy: as-needed; + content := VerticalLayout { + width: parent.width; height: self.preferred-height; + padding: 0px; padding-right: 22px; spacing: 16px; + AboutNavigationGroup { + AboutNavigationRow { + grouped: true; + title: FeedbackStrings.github; + subtitle: FeedbackStrings.github-hint; + external: true; + clicked => { root.open-github(); } + } + AboutRowSeparator { } + AboutNavigationRow { + grouped: true; + title: root.exporting ? FeedbackStrings.exporting : FeedbackStrings.export; + subtitle: FeedbackStrings.export-hint; + enabled: !root.exporting; + clicked => { root.export-logs(); } + } + } + Text { + text: FeedbackStrings.attachment-hint; + color: WorkbenchStyle.muted; + font-size: 12px; wrap: word-wrap; + } + if root.status != "": Rectangle { + height: result.preferred-height + 24px; + background: root.succeeded ? #e9f6ef : #fef2f2; + border-radius: UiRadius.control; + result := VerticalLayout { + x: 12px; y: 12px; width: parent.width - 24px; + height: self.preferred-height; padding: 0px; spacing: 6px; + Text { + text: root.status; + color: root.succeeded ? WorkbenchStyle.success : #b3261e; + font-size: 12px; wrap: word-wrap; + accessible-role: text; + } + if root.saved-path != "": Text { + text: root.saved-path; color: WorkbenchStyle.muted; + font-size: 11px; wrap: char-wrap; + } + } + } + } + } + footer := VerticalLayout { + x: 24px; y: parent.height - self.height - 24px; + width: parent.width - 48px; height: self.preferred-height; + spacing: 16px; + SurfaceSeparator { height: 1px; outline-color: UiSurface.separator; } + HorizontalLayout { + alignment: end; + SettingsButton { + width: max(80px, self.label-width); + text: FeedbackStrings.close; + primary: true; + clicked => { root.close(); } + } + } + } +} diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 0cac7b6f..f0f4228e 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -2,6 +2,7 @@ import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint"; import { TrustedDevicesDialog, TrustedDeviceEntry, TrustStrings } from "trusted_devices.slint"; import { LanStrings } from "lan_strings.slint"; import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint"; +import { FeedbackPanel, FeedbackStrings } from "feedback_panel.slint"; import { UiRadius } from "ui_radius.slint"; import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint"; import { GalleryStrings } from "device_strings.slint"; @@ -849,6 +850,13 @@ export component MainWindow inherits Window { in property about-source-url; in property about-build-url; callback open-about-link(string); + callback open-feedback-github; + callback export-diagnostic-logs; + in-out property feedback-open: false; + in property feedback-exporting: false; + in property feedback-status; + in property feedback-succeeded: false; + in property feedback-saved-path; function about-back() { if root.about-page == AboutPage.document { root.about-page = AboutPage.component; } else if root.about-page == AboutPage.component && root.about-component-index != 0 { root.about-page = AboutPage.components; } @@ -858,7 +866,7 @@ export component MainWindow inherits Window { // Automatic prompts must not interrupt another dialog or menu. private property sponsorship-unobstructed: !root.gallery-mode && !root.menu-open && !root.settings-open - && !root.self-host-settings-open && !root.about-open + && !root.self-host-settings-open && !root.about-open && !root.feedback-open && !(root.update-open && root.update-available) && !root.messages-open && !root.update-confirm-open && !root.reset-password-open && !root.alias-open && !root.delete-open @@ -922,7 +930,7 @@ export component MainWindow inherits Window { triggered => { root.dialog-focus-pending = false; if root.settings-open || root.autostart-guide-open - || root.wayland-notice-open || root.messages-open || root.about-open + || root.wayland-notice-open || root.messages-open || root.about-open || root.feedback-open || (root.sponsorship-open && root.sponsorship-unobstructed) { focus-sink.focus(); } @@ -960,6 +968,13 @@ export component MainWindow inherits Window { if root.messages-open { root.dialog-focus-pending = true; } } + changed feedback-open => { + if root.feedback-open { + root.gallery-action("release", ""); + root.dialog-focus-pending = true; + } + } + changed about-open => { if root.about-open { root.dialog-focus-pending = true; } else { @@ -1188,6 +1203,10 @@ export component MainWindow inherits Window { focus-sink := FocusScope { x: 0px; y: 0px; width: 0px; height: 0px; key-pressed(event) => { + if root.feedback-open { + if event.text == Key.Escape { root.feedback-open = false; } + return accept; + } if root.autostart-guide-open { if event.text == Key.Return || event.text == Key.Escape { root.autostart-guide-open = false; } return accept; @@ -2195,9 +2214,9 @@ export component MainWindow inherits Window { if !root.gallery-mode && root.menu-open: FlatSurface { x: menu-button.x + menu-button.width - self.width; y: menu-button.y + menu-button.height + 6px; - width: max(128px, max(menu-settings-label.preferred-width, + width: max(128px, max(menu-feedback-label.preferred-width, max(menu-settings-label.preferred-width, max(menu-messages-label.preferred-width + (root.announcement-unread > 0 ? menu-message-badge.width + 8px : 0px), - menu-about-label.preferred-width + (root.update-available ? menu-update-badge.width + 8px : 0px))) + 28px); + menu-about-label.preferred-width + (root.update-available ? menu-update-badge.width + 8px : 0px)))) + 28px); height: application-menu-content.preferred-height; background: WorkbenchStyle.surface; outline-color: UiSurface.edge; @@ -2327,6 +2346,54 @@ export component MainWindow inherits Window { clicked => { root.menu-open = false; root.about-open = true; } } } + Rectangle { + height: 7px; + SurfaceSeparator { + x: 8px; y: 3px; width: parent.width - 16px; height: 1px; + outline-color: UiSurface.separator; + } + } + Rectangle { + height: 30px; + background: feedback-touch.pressed ? WorkbenchStyle.pressed-surface + : feedback-touch.has-hover ? WorkbenchStyle.hover-surface : transparent; + border-radius: UiRadius.control; + animate background { duration: 120ms; } + accessible-role: button; + accessible-label: FeedbackStrings.title; + accessible-action-default => { feedback-touch.clicked(); } + menu-feedback-label := Text { + x: 8px; height: parent.height; + text: FeedbackStrings.title; + color: WorkbenchStyle.text; font-size: 12px; + vertical-alignment: center; + accessible-role: none; + } + feedback-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.menu-open = false; root.feedback-open = true; } + } + } + } + } + + if root.feedback-open: Rectangle { + background: #15223850; + z: 40; + TouchArea { clicked => { focus-sink.focus(); } } + FeedbackPanel { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: min(480px, parent.width - 40px); + min-height: 0px; max-height: parent.height - 32px; + height: min(self.preferred-dialog-height, parent.height - 32px); + exporting: root.feedback-exporting; + status: root.feedback-status; + succeeded: root.feedback-succeeded; + saved-path: root.feedback-saved-path; + open-github => { root.open-feedback-github(); } + export-logs => { root.export-diagnostic-logs(); } + close => { root.feedback-open = false; } } } diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 821bea8c..1c167888 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -73,6 +73,15 @@ inline int ApplyMainWindowStrings( trust.set_trust(Text(localization::Literal("信任此设备", language))); trust.set_remove(Text(localization::Literal("移除信任", language))); trust.set_close(Text(localization::message_close[language])); + auto& feedback = window->global(); + feedback.set_title(Text(localization::Literal("反馈与建议", language))); + feedback.set_github(Text(localization::Literal("前往 GitHub 反馈", language))); + feedback.set_github_hint(Text(localization::Literal("报告问题或提出功能建议。", language))); + feedback.set_export(Text(localization::Literal("导出诊断日志", language))); + feedback.set_export_hint(Text(localization::Literal("将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。", language))); + feedback.set_exporting(Text(localization::Literal("正在导出…", language))); + feedback.set_attachment_hint(Text(localization::Literal("请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。", language))); + feedback.set_close(Text(localization::message_close[language])); auto& strings = window->global(); strings.set_system_permissions(Text(localization::system_permissions[language])); strings.set_permission_migration_running(Text(localization::permission_migration_running[language])); diff --git a/apps/desktop/src/platform/macos/gui/window_drag_mac.mm b/apps/desktop/src/platform/macos/gui/window_drag_mac.mm index 3785b49c..78965e90 100644 --- a/apps/desktop/src/platform/macos/gui/window_drag_mac.mm +++ b/apps/desktop/src/platform/macos/gui/window_drag_mac.mm @@ -1,6 +1,7 @@ #include "platform/window_drag.h" #import +#import #include #include @@ -46,9 +47,10 @@ NSApplicationPresentationOptions stream_restore_presentation_options = struct FileDialogRequest { std::string title; std::function completion; + std::string filename; }; std::shared_ptr file_dialog_request; -NSOpenPanel *file_dialog = nil; +NSSavePanel *file_dialog = nil; NSOpenPanel *prepared_file_dialog = nil; uint64_t file_dialog_prepare_generation = 0; @@ -500,12 +502,47 @@ void OpenNativeFileDialog(const std::string& title, }); } +void SaveNativeFileDialog(const std::string& title, const std::string& filename, + std::function completion) { + if (file_dialog_request) { completion({}); return; } + const auto request = std::make_shared( + FileDialogRequest{title, std::move(completion), filename}); + file_dialog_request = request; + dispatch_async(dispatch_get_main_queue(), ^{ + if (file_dialog_request != request) return; + NSSavePanel *panel = [NSSavePanel savePanel]; + file_dialog = panel; + panel.title = [NSString stringWithUTF8String:request->title.c_str()]; + panel.nameFieldStringValue = [NSString stringWithUTF8String:request->filename.c_str()]; + panel.allowedContentTypes = @[UTTypeZIP]; + panel.allowsOtherFileTypes = NO; + panel.canCreateDirectories = YES; + [NSApp activateIgnoringOtherApps:YES]; + NSWindow *owner = NSApp.keyWindow ?: NSApp.mainWindow; + CenterWindowOnScreen(panel, owner.screen ?: NSScreen.mainScreen); + __weak NSSavePanel *weak_panel = panel; + [panel beginWithCompletionHandler:^(NSModalResponse result) { + if (file_dialog_request != request) return; + NSSavePanel *finished_panel = weak_panel; + const char *selected = result == NSModalResponseOK + ? finished_panel.URL.fileSystemRepresentation : nullptr; + const std::string path = selected ? selected : ""; + [finished_panel orderOut:nil]; + file_dialog = nil; + file_dialog_request.reset(); + auto finished = std::move(request->completion); + finished(path); + }]; + if (file_dialog_request == request) [panel makeKeyAndOrderFront:nil]; + }); +} + void CancelNativeFileDialog() { ++file_dialog_prepare_generation; prepared_file_dialog = nil; const auto request = std::move(file_dialog_request); if (!request) return; - NSOpenPanel *panel = file_dialog; + NSSavePanel *panel = file_dialog; file_dialog = nil; [panel cancel:nil]; [panel orderOut:nil]; diff --git a/apps/desktop/src/platform/window_drag.h b/apps/desktop/src/platform/window_drag.h index 2367e2b3..5b9fda2f 100644 --- a/apps/desktop/src/platform/window_drag.h +++ b/apps/desktop/src/platform/window_drag.h @@ -62,6 +62,9 @@ void PrepareNativeFileDialog(); // nested modal loop. Completion receives an empty path on cancel or failure. void OpenNativeFileDialog(const std::string& title, std::function completion); +// Asynchronous ZIP destination picker with native overwrite confirmation. +void SaveNativeFileDialog(const std::string& title, const std::string& filename, + std::function completion); // Cancels an open or pending picker, completes it once with an empty path, and // releases the cached panel and pending initialization during application exit. void CancelNativeFileDialog(); diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 92110a97..354b9611 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -663,6 +663,7 @@ function setup_targets() "apps/desktop/src/gui/rendering/*.cpp", "apps/desktop/src/gui/runtime/*.cpp", "apps/desktop/src/gui/features/about/*.cpp", + "apps/desktop/src/gui/features/feedback/*.cpp", "apps/desktop/src/platform/common/gui/service_status_runtime.cpp", "apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp", "apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp", @@ -704,7 +705,7 @@ function setup_targets() "apps/desktop/src/platform/macos/gui/window_drag_mac.mm", "apps/desktop/src/platform/macos/gui/metal_video_renderer.mm", "apps/desktop/src/platform/macos/gui/video_renderer_factory_metal.mm") - add_frameworks("Security", {public = true}) + add_frameworks("Security", "UniformTypeIdentifiers", {public = true}) add_includedirs("apps/desktop/src/platform/macos/gui", "apps/desktop/src/platform/macos/gui/tray") elseif is_os("linux") then diff --git a/localization/catalog.json b/localization/catalog.json index 6613ecbb..bc26157e 100644 --- a/localization/catalog.json +++ b/localization/catalog.json @@ -10895,6 +10895,162 @@ "fr": "La nouvelle version nécessite iOS %1$@ ou une version ultérieure", "es": "La nueva versión requiere iOS %1$@ o posterior", "pt-BR": "A nova versão requer iOS %1$@ ou posterior" + }, + "前往 GitHub 反馈": { + "zh-Hans": "前往 GitHub 反馈", + "en": "Give feedback on GitHub", + "ru": "Оставить отзыв на GitHub", + "zh-Hant": "前往 GitHub 回饋", + "ja": "GitHub でフィードバック", + "ko": "GitHub에 피드백 보내기", + "de": "Feedback auf GitHub geben", + "fr": "Donner un avis sur GitHub", + "es": "Enviar comentarios en GitHub", + "pt-BR": "Enviar comentários no GitHub" + }, + "反馈与建议": { + "zh-Hans": "反馈与建议", + "en": "Feedback & suggestions", + "ru": "Отзывы и предложения", + "zh-Hant": "回饋與建議", + "ja": "フィードバック・ご提案", + "ko": "피드백 및 제안", + "de": "Feedback und Vorschläge", + "fr": "Avis et suggestions", + "es": "Comentarios y sugerencias", + "pt-BR": "Comentários e sugestões" + }, + "导出诊断日志": { + "zh-Hans": "导出诊断日志", + "en": "Export diagnostic logs", + "ru": "Экспорт журналов диагностики", + "zh-Hant": "匯出診斷日誌", + "ja": "診断ログをエクスポート", + "ko": "진단 로그 내보내기", + "de": "Diagnoseprotokolle exportieren", + "fr": "Exporter les journaux de diagnostic", + "es": "Exportar registros de diagnóstico", + "pt-BR": "Exportar logs de diagnóstico" + }, + "将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。": { + "zh-Hans": "将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。", + "en": "Save CrossDesk and MiniRTC logs with basic version information as a ZIP file.", + "ru": "Сохраните журналы CrossDesk и MiniRTC и сведения о версии в ZIP-файл.", + "zh-Hant": "將 CrossDesk、MiniRTC 日誌和基本版本資訊儲存為 ZIP 檔案。", + "ja": "CrossDesk・MiniRTC のログと基本的なバージョン情報を ZIP ファイルに保存します。", + "ko": "CrossDesk 및 MiniRTC 로그와 기본 버전 정보를 ZIP 파일로 저장합니다.", + "de": "Speichern Sie CrossDesk- und MiniRTC-Protokolle mit Versionsinformationen als ZIP-Datei.", + "fr": "Enregistrez les journaux CrossDesk et MiniRTC et les informations de version dans un fichier ZIP.", + "es": "Guarda los registros de CrossDesk y MiniRTC con la información de versión en un archivo ZIP.", + "pt-BR": "Salve os logs do CrossDesk e do MiniRTC com informações de versão em um arquivo ZIP." + }, + "报告问题或提出功能建议。": { + "zh-Hans": "报告问题或提出功能建议。", + "en": "Report a problem or suggest an improvement.", + "ru": "Сообщите о проблеме или предложите улучшение.", + "zh-Hant": "回報問題或提出功能建議。", + "ja": "問題の報告や機能の改善をご提案ください。", + "ko": "문제를 보고하거나 개선 사항을 제안하세요.", + "de": "Melden Sie ein Problem oder schlagen Sie eine Verbesserung vor.", + "fr": "Signalez un problème ou proposez une amélioration.", + "es": "Informa de un problema o sugiere una mejora.", + "pt-BR": "Relate um problema ou sugira uma melhoria." + }, + "无法保存诊断包,请选择其他位置重试。": { + "zh-Hans": "无法保存诊断包,请选择其他位置重试。", + "en": "Could not save the diagnostic archive. Choose another location and try again.", + "ru": "Не удалось сохранить архив диагностики. Выберите другое расположение и повторите попытку.", + "zh-Hant": "無法儲存診斷包,請選擇其他位置重試。", + "ja": "診断アーカイブを保存できません。別の場所を選んで再度お試しください。", + "ko": "진단 파일을 저장할 수 없습니다. 다른 위치를 선택하고 다시 시도하세요.", + "de": "Das Diagnosearchiv konnte nicht gespeichert werden. Wählen Sie einen anderen Speicherort und versuchen Sie es erneut.", + "fr": "Impossible de sauvegarder l’archive de diagnostic. Choisissez un autre emplacement et réessayez.", + "es": "No se pudo guardar el archivo de diagnóstico. Elige otra ubicación e inténtalo de nuevo.", + "pt-BR": "Não foi possível salvar o arquivo de diagnóstico. Escolha outro local e tente novamente." + }, + "无法打开浏览器,请访问 GitHub 项目页面。": { + "zh-Hans": "无法打开浏览器,请访问 GitHub 项目页面。", + "en": "Could not open the browser. Please visit the project on GitHub.", + "ru": "Не удалось открыть браузер. Откройте страницу проекта на GitHub.", + "zh-Hant": "無法開啟瀏覽器,請前往 GitHub 專案頁面。", + "ja": "ブラウザーを開けません。GitHub のプロジェクトページにアクセスしてください。", + "ko": "브라우저를 열 수 없습니다. GitHub 프로젝트 페이지를 방문하세요.", + "de": "Der Browser konnte nicht geöffnet werden. Besuchen Sie die Projektseite auf GitHub.", + "fr": "Impossible d’ouvrir le navigateur. Consultez la page du projet sur GitHub.", + "es": "No se pudo abrir el navegador. Visita la página del proyecto en GitHub.", + "pt-BR": "Não foi possível abrir o navegador. Acesse a página do projeto no GitHub." + }, + "无法读取日志,请稍后重试。": { + "zh-Hans": "无法读取日志,请稍后重试。", + "en": "Could not read the logs. Please try again later.", + "ru": "Не удалось прочитать журналы. Повторите попытку позже.", + "zh-Hant": "無法讀取日誌,請稍後重試。", + "ja": "ログを読み取れません。後でもう一度お試しください。", + "ko": "로그를 읽을 수 없습니다. 나중에 다시 시도하세요.", + "de": "Die Protokolle konnten nicht gelesen werden. Versuchen Sie es später erneut.", + "fr": "Impossible de lire les journaux. Réessayez plus tard.", + "es": "No se pudieron leer los registros. Inténtalo de nuevo más tarde.", + "pt-BR": "Não foi possível ler os logs. Tente novamente mais tarde." + }, + "日志文件过大,无法导出。": { + "zh-Hans": "日志文件过大,无法导出。", + "en": "The log file is too large to export.", + "ru": "Файл журнала слишком велик для экспорта.", + "zh-Hant": "日誌檔案過大,無法匯出。", + "ja": "ログファイルが大きすぎるためエクスポートできません。", + "ko": "로그 파일이 너무 커서 내보낼 수 없습니다.", + "de": "Die Protokolldatei ist zu groß für den Export.", + "fr": "Le fichier journal est trop volumineux pour être exporté.", + "es": "El archivo de registro es demasiado grande para exportarlo.", + "pt-BR": "O arquivo de log é grande demais para exportar." + }, + "正在导出…": { + "zh-Hans": "正在导出…", + "en": "Exporting…", + "ru": "Экспорт…", + "zh-Hant": "正在匯出…", + "ja": "エクスポート中…", + "ko": "내보내는 중…", + "de": "Export wird erstellt…", + "fr": "Exportation…", + "es": "Exportando…", + "pt-BR": "Exportando…" + }, + "没有找到可导出的日志。": { + "zh-Hans": "没有找到可导出的日志。", + "en": "No logs were found to export.", + "ru": "Журналы для экспорта не найдены.", + "zh-Hant": "找不到可匯出的日誌。", + "ja": "エクスポートできるログが見つかりません。", + "ko": "내보낼 로그를 찾을 수 없습니다.", + "de": "Keine Protokolle zum Exportieren gefunden.", + "fr": "Aucun journal à exporter trouvé.", + "es": "No se encontraron registros para exportar.", + "pt-BR": "Nenhum log encontrado para exportar." + }, + "诊断日志已导出,请将 ZIP 附到 GitHub Issue。": { + "zh-Hans": "诊断日志已导出,请将 ZIP 附到 GitHub Issue。", + "en": "Diagnostic logs exported. Attach the ZIP to your GitHub issue.", + "ru": "Журналы диагностики экспортированы. Прикрепите ZIP-файл к обращению на GitHub.", + "zh-Hant": "診斷日誌已匯出,請將 ZIP 附到 GitHub Issue。", + "ja": "診断ログをエクスポートしました。ZIP を GitHub Issue に添付してください。", + "ko": "진단 로그를 내보냈습니다. GitHub 이슈에 ZIP을 첨부하세요.", + "de": "Diagnoseprotokolle exportiert. Hängen Sie die ZIP-Datei an Ihr GitHub-Issue an.", + "fr": "Journaux de diagnostic exportés. Joignez le ZIP à votre ticket GitHub.", + "es": "Registros de diagnóstico exportados. Adjunta el ZIP a tu issue de GitHub.", + "pt-BR": "Logs de diagnóstico exportados. Anexe o ZIP à sua issue do GitHub." + }, + "请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。": { + "zh-Hans": "请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。", + "en": "Attach the exported ZIP log archive to your GitHub issue to help us investigate.", + "ru": "Прикрепите экспортированный ZIP-архив журналов к обращению на GitHub, чтобы помочь нам разобраться в проблеме.", + "zh-Hant": "請在 GitHub Issue 中附上匯出的 ZIP 日誌封存檔,便於排查問題。", + "ja": "問題の調査のため、エクスポートしたログの ZIP ファイルを GitHub Issue に添付してください。", + "ko": "문제 조사에 도움이 되도록 내보낸 ZIP 로그 파일을 GitHub 이슈에 첨부해 주세요.", + "de": "Hängen Sie das exportierte ZIP-Archiv mit den Protokollen an Ihr GitHub-Issue an, damit wir das Problem untersuchen können.", + "fr": "Joignez l’archive ZIP des journaux exportés à votre ticket GitHub pour nous aider à analyser le problème.", + "es": "Adjunta el archivo ZIP de los registros exportados a tu issue de GitHub para ayudarnos a investigar el problema.", + "pt-BR": "Anexe o arquivo ZIP dos logs exportados à sua issue do GitHub para nos ajudar a investigar o problema." } }, "desktop": { @@ -11937,7 +12093,20 @@ "远端 Windows 服务不可用,锁屏时无法操作。请在远端安装或修复 CrossDesk 服务。", "开机自启需要安装版 Release;便携版和 Debug 不支持无人值守服务。", "无法配置无人值守服务,请检查管理员权限和安装文件。", - "开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。" + "开机后无需登录即可连接;登录后继续使用同一 ID、密码和完整功能。", + "前往 GitHub 反馈", + "反馈与建议", + "导出诊断日志", + "将 CrossDesk、MiniRTC 日志和基本版本信息保存为 ZIP 文件。", + "报告问题或提出功能建议。", + "无法保存诊断包,请选择其他位置重试。", + "无法打开浏览器,请访问 GitHub 项目页面。", + "无法读取日志,请稍后重试。", + "日志文件过大,无法导出。", + "正在导出…", + "没有找到可导出的日志。", + "诊断日志已导出,请将 ZIP 附到 GitHub Issue。", + "请在 GitHub Issue 中附上导出的 ZIP 日志包,便于排查问题。" ], "android": [ " · 已完成",