mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-11 10:59:06 +08:00
[feat] add feedback dialog and diagnostic log export, refs #149
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -26,6 +27,7 @@
|
||||
#include <set>
|
||||
#include <shared_mutex>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
@@ -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<void> file_dialog_lifetime = std::make_shared<int>(0);
|
||||
bool file_dialog_open = false;
|
||||
std::future<feedback::ExportResult> 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<void> 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<char>(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());
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ private:
|
||||
void TickLocalHost();
|
||||
#endif
|
||||
void PollUpdateCheck();
|
||||
void ExportDiagnosticLogs();
|
||||
void PollDiagnosticExport();
|
||||
void ShareLocalCursorState();
|
||||
bool RequestPasswordChange(const std::string &password);
|
||||
void HandlePasswordChangeResult();
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "diagnostic_export.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<char>(value >> (i * 8));
|
||||
}
|
||||
uint32_t Crc32(std::string_view text) {
|
||||
static const auto table = [] {
|
||||
std::array<uint32_t, 256> 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<Entry>& entries) {
|
||||
std::string out;
|
||||
for (auto& entry : entries) {
|
||||
entry.offset = static_cast<uint32_t>(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<Entry> 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<size_t>(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<std::streamsize>(size));
|
||||
if (input.bad() || static_cast<size_t>(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
|
||||
@@ -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 <ctime>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -55,6 +55,7 @@ export component AboutNavigationRow inherits FlatSurface {
|
||||
in property <string> detail;
|
||||
in property <bool> grouped: false;
|
||||
in property <bool> external: false;
|
||||
in property <bool> 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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 <string> title: "Feedback & suggestions";
|
||||
in-out property <string> github: "Give feedback on GitHub";
|
||||
in-out property <string> github-hint: "Report a problem or suggest an improvement.";
|
||||
in-out property <string> export: "Export diagnostic logs";
|
||||
in-out property <string> export-hint: "Save CrossDesk and MiniRTC logs with basic version information as a ZIP file.";
|
||||
in-out property <string> exporting: "Exporting…";
|
||||
in-out property <string> attachment-hint: "Attach the exported ZIP log archive to your GitHub issue to help us investigate.";
|
||||
in-out property <string> close: "Close";
|
||||
}
|
||||
|
||||
export component FeedbackPanel inherits SettingsDialog {
|
||||
in property <bool> exporting: false;
|
||||
in property <string> status;
|
||||
in property <bool> succeeded: false;
|
||||
in property <string> saved-path;
|
||||
out property <length> 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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <string> about-source-url;
|
||||
in property <string> about-build-url;
|
||||
callback open-about-link(string);
|
||||
callback open-feedback-github;
|
||||
callback export-diagnostic-logs;
|
||||
in-out property <bool> feedback-open: false;
|
||||
in property <bool> feedback-exporting: false;
|
||||
in property <string> feedback-status;
|
||||
in property <bool> feedback-succeeded: false;
|
||||
in property <string> 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 <bool> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ui::FeedbackStrings>();
|
||||
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<ui::UiStrings>();
|
||||
strings.set_system_permissions(Text(localization::system_permissions[language]));
|
||||
strings.set_permission_migration_running(Text(localization::permission_migration_running[language]));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "platform/window_drag.h"
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -46,9 +47,10 @@ NSApplicationPresentationOptions stream_restore_presentation_options =
|
||||
struct FileDialogRequest {
|
||||
std::string title;
|
||||
std::function<void(std::string)> completion;
|
||||
std::string filename;
|
||||
};
|
||||
std::shared_ptr<FileDialogRequest> 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<void(std::string)> completion) {
|
||||
if (file_dialog_request) { completion({}); return; }
|
||||
const auto request = std::make_shared<FileDialogRequest>(
|
||||
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];
|
||||
|
||||
@@ -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<void(std::string)> completion);
|
||||
// Asynchronous ZIP destination picker with native overwrite confirmation.
|
||||
void SaveNativeFileDialog(const std::string& title, const std::string& filename,
|
||||
std::function<void(std::string)> 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();
|
||||
|
||||
Reference in New Issue
Block a user