From 171e559b0d3c2711f355e1274e0e74cb74f2c924 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 21 Oct 2026 11:45:27 +0800 Subject: [PATCH] [feat] add desktop license and source information pages --- .../src/gui/application/gui_application.cpp | 6 + .../src/gui/features/about/about_catalog.cpp | 115 +++++++++ .../src/gui/features/about/about_catalog.h | 18 ++ apps/desktop/src/gui/ui/about_panel.slint | 235 ++++++++++++++++++ apps/desktop/src/gui/ui/common.slint | 4 + apps/desktop/src/gui/ui/crossdesk_ui.slint | 1 + apps/desktop/src/gui/ui/main_window.slint | 91 ++++++- apps/desktop/xmake/rules/about_resources.lua | 134 ++++++++++ apps/desktop/xmake/targets.lua | 2 + apps/harmony/scripts/build_desktop.py | 53 ++++ xmake.lua | 1 + 11 files changed, 647 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/gui/features/about/about_catalog.cpp create mode 100644 apps/desktop/src/gui/features/about/about_catalog.h create mode 100644 apps/desktop/src/gui/ui/about_panel.slint create mode 100644 apps/desktop/xmake/rules/about_resources.lua diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index bd4929ad..a6eeb50f 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -36,6 +36,7 @@ #include "crossdesk_ui.h" #include "fa_solid_900.h" +#include "features/about/about_catalog.h" #include "features/file_transfer/transfer_notification_layout.h" #include "features/gallery/gallery_fullscreen_transition.h" #include "features/gallery/gallery_window_state.h" @@ -1785,6 +1786,11 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { window->set_update_install_busy(updater_->busy()); #endif }); + about::Populate(ui_->Window(gallery_window)); + main->on_open_about_link([this](slint::SharedString url) { + const std::string link(url); + if (link.starts_with("https://")) OpenUrl(link); + }); main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); }); main->on_open_afdian_sponsor([this] { OpenUrl("https://afdian.com/a/kunkundi"); diff --git a/apps/desktop/src/gui/features/about/about_catalog.cpp b/apps/desktop/src/gui/features/about/about_catalog.cpp new file mode 100644 index 00000000..9405e14f --- /dev/null +++ b/apps/desktop/src/gui/features/about/about_catalog.cpp @@ -0,0 +1,115 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-21 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "about_catalog.h" + +#include + +#include +#include +#include +#include +#include + +#include "about_resources.h" + +namespace crossdesk::about { +namespace { + +slint::SharedString Text(const nlohmann::json& object, const char* key) { + return slint::SharedString(object.value(key, std::string{})); +} + +std::string_view TrimLayoutWhitespace(std::string_view line) { + const auto first = line.find_first_not_of(" \t\r"); + if (first == std::string_view::npos) return {}; + return line.substr(first, line.find_last_not_of(" \t\r") - first + 1); +} + +slint::SharedString DocumentText(const nlohmann::json& document) { + const auto title = document.value("title", std::string{}); + const auto text = document.value("text", std::string{}); + if (title != "GNU GPL v3" && title != "GNU LGPL v3") { + return slint::SharedString(text); + } + + // GNU's plain-text copies use fixed-width wrapping and space-aligned headings. + // Reflow prose for the reader, retaining paragraph boundaries, notice examples, + // and every non-whitespace character. The embedded source stays untouched. + std::string formatted; + std::vector paragraph; + auto append_paragraph = [&] { + if (paragraph.empty()) return; + if (!formatted.empty()) formatted += "\n\n"; + const bool preserve_lines = paragraph.front().starts_with("GNU ") || + paragraph.front().starts_with("<"); + for (size_t index = 0; index < paragraph.size(); ++index) { + if (index > 0) { + const bool after_copyright = + paragraph[index - 1].starts_with("Copyright (C)"); + formatted += preserve_lines || after_copyright ? '\n' : ' '; + } + formatted += paragraph[index]; + } + paragraph.clear(); + }; + std::string_view remaining(text); + while (!remaining.empty()) { + const auto end = remaining.find('\n'); + const auto line = TrimLayoutWhitespace(remaining.substr(0, end)); + if (line.empty()) { + append_paragraph(); + } else { + paragraph.push_back(line); + } + if (end == std::string_view::npos) break; + remaining.remove_prefix(end + 1); + } + append_paragraph(); + return slint::SharedString(formatted); +} + +} // namespace + +void Populate(const slint::ComponentHandle& window) { + // Both the main window and DeskHub share the immutable, embedded catalog. + static const auto catalog = nlohmann::json::parse( + std::begin(kAboutResources), std::end(kAboutResources)); + static const auto model = [] { + std::vector components; + for (const auto& item : catalog.at("components")) { + ui::AboutComponent component; + component.id = Text(item, "id"); + component.name = Text(item, "name"); + component.version = Text(item, "version"); + component.license = Text(item, "license"); + component.source_url = Text(item, "sourceURL"); + std::vector documents; + for (const auto& document : item.at("documents")) { + ui::AboutDocument entry; + entry.title = Text(document, "title"); + entry.text = DocumentText(document); + documents.push_back(std::move(entry)); + } + component.documents = + std::make_shared>(documents); + components.push_back(std::move(component)); + } + return std::make_shared>(components); + }(); + window->set_about_components(model); + for (size_t index = 0; index < model->row_count(); ++index) { + if (model->row_data(index)->id == "openfec") { + window->set_about_openfec_index(static_cast(index)); + break; + } + } + window->set_about_revision(Text(catalog, "revision")); + window->set_about_source_url(Text(catalog, "sourceURL")); + window->set_about_build_url(Text(catalog, "buildURL")); +} + +} // namespace crossdesk::about diff --git a/apps/desktop/src/gui/features/about/about_catalog.h b/apps/desktop/src/gui/features/about/about_catalog.h new file mode 100644 index 00000000..aa5c9255 --- /dev/null +++ b/apps/desktop/src/gui/features/about/about_catalog.h @@ -0,0 +1,18 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-21 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _ABOUT_CATALOG_H_ +#define _ABOUT_CATALOG_H_ + +#include "crossdesk_ui.h" + +namespace crossdesk::about { + +void Populate(const slint::ComponentHandle& window); + +} // namespace crossdesk::about + +#endif diff --git a/apps/desktop/src/gui/ui/about_panel.slint b/apps/desktop/src/gui/ui/about_panel.slint new file mode 100644 index 00000000..9e8f73d9 --- /dev/null +++ b/apps/desktop/src/gui/ui/about_panel.slint @@ -0,0 +1,235 @@ +import { ScrollView, TextEdit } from "std-widgets.slint"; +import { SettingsDialog, SettingsButton } from "settings_controls.slint"; +import { WorkbenchStyle } from "workbench_controls.slint"; +import { UiRadius } from "ui_radius.slint"; +import { GalleryStrings } from "device_strings.slint"; +import { FontAwesomeIcons } from "common.slint"; +import "../assets/fonts/message_spacing.ttf"; + +export enum AboutPage { overview, components, component, document, source } +export struct AboutDocument { title: string, text: string } +export struct AboutComponent { + id: string, + name: string, + version: string, + license: string, + source-url: string, + documents: [AboutDocument], +} + +export global AboutStrings { + out property software-license: GalleryStrings.tr("软件许可", "Software license", "Лицензия программы"); + out property components: GalleryStrings.tr("开源组件", "Open-source components", "Компоненты с открытым кодом"); + out property source: GalleryStrings.tr("源码与构建说明", "Source code and build instructions", "Исходный код и сборка"); + out property notices: GalleryStrings.tr("许可与版权声明", "Licenses and copyright notices", "Лицензии и авторские права"); + out property view-source: GalleryStrings.tr("查看源码", "View source code", "Открыть исходный код"); + out property back: GalleryStrings.tr("返回", "Back", "Назад"); +} + +export component AboutNavigationGroup inherits Rectangle { + height: rows.preferred-height + 2px; + background: WorkbenchStyle.surface; + border-radius: UiRadius.control; + border-width: 1px; border-color: WorkbenchStyle.separator; + clip: true; + rows := VerticalLayout { + x: 1px; y: 1px; width: parent.width - 2px; + height: self.preferred-height; padding: 0px; spacing: 0px; + @children + } +} + +export component AboutRowSeparator inherits Rectangle { + height: 1px; + Rectangle { + x: 52px; width: parent.width - 68px; height: 1px; + background: WorkbenchStyle.separator; + } +} + +export component AboutNavigationRow inherits Rectangle { + in property title; + in property subtitle; + in property icon; + in property detail; + in property grouped: false; + in property external: false; + callback clicked; + min-width: 0px; + min-height: max(44px, labels.preferred-height + 20px); + background: touch.pressed ? WorkbenchStyle.pressed-surface + : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface; + border-radius: root.grouped ? 0px : UiRadius.control; + border-width: root.grouped ? 0px : 1px; border-color: WorkbenchStyle.separator; + accessible-role: button; + accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "") + + (root.detail != "" ? ", " + root.detail : ""); + accessible-action-default => { root.clicked(); } + if root.icon != "": Rectangle { + x: 12px; y: (parent.height - self.height) / 2; + width: 28px; height: 28px; + border-radius: UiRadius.control; + background: WorkbenchStyle.field-surface; + Text { + text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900; + font-size: 13px; color: WorkbenchStyle.muted; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } + } + labels := VerticalLayout { + x: root.icon != "" ? 52px : 14px; + y: (parent.height - self.height) / 2; + width: parent.width - self.x - 36px - (root.detail != "" ? detail-label.width + 12px : 0px); + height: self.preferred-height; spacing: 4px; + Text { text: root.title; color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap; } + if root.subtitle != "": Text { + text: root.subtitle; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; + } + } + detail-label := Text { + x: parent.width - self.width - 36px; height: parent.height; + width: min(100px, self.preferred-width); + text: root.detail; color: WorkbenchStyle.muted; font-size: 11px; + vertical-alignment: center; overflow: elide; + accessible-role: none; + } + Text { + x: parent.width - 30px; width: 18px; height: parent.height; + text: root.external ? FontAwesomeIcons.arrow-up-right-from-square : FontAwesomeIcons.angle-right; + font-family: "Font Awesome 6 Free"; font-weight: 900; + color: touch.has-hover ? WorkbenchStyle.accent : WorkbenchStyle.muted; + font-size: 11px; vertical-alignment: center; horizontal-alignment: center; + accessible-role: none; + } + touch := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } } +} + +export component AboutPanel inherits SettingsDialog { + show-close-button: false; + in-out property page: AboutPage.components; + in-out property component-index: 0; + private property document-index: 0; + in property <[AboutComponent]> components; + in property openfec-index: -1; + in property revision; + in property source-url; + in property build-url; + private property selected: root.components[root.component-index]; + callback back; + callback open-link(string); + title: root.page == AboutPage.components ? AboutStrings.components + : root.page == AboutPage.source ? AboutStrings.source + : root.page == AboutPage.document ? AboutStrings.notices + : root.selected.name; + + if root.page != AboutPage.document: 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: 10px; + if root.page == AboutPage.components: VerticalLayout { + padding: 0px; spacing: 10px; + Text { + text: GalleryStrings.tr("以下列出本版本的主要开源组件。各组件遵循各自的开源许可。", "Main open-source components used in this build. Each component is covered by its own license.", "Основные открытые компоненты этой сборки. Каждый распространяется по своей лицензии."); + color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; + } + Rectangle { + height: notice.preferred-height + 32px; + background: WorkbenchStyle.field-surface; border-radius: UiRadius.control; + notice := VerticalLayout { + x: 16px; y: 16px; width: parent.width - 32px; + height: self.preferred-height; spacing: 12px; + Text { text: GalleryStrings.tr("OpenFEC 告知", "OpenFEC notice", "Уведомление OpenFEC"); color: WorkbenchStyle.text; font-size: 14px; font-weight: 600; } + Text { + text: GalleryStrings.tr("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。", "This application uses OpenFEC for forward error correction under CeCILL-C 1.0, which provides limited warranties and limits liability. See the full license for details.", "Приложение использует OpenFEC для прямого исправления ошибок по лицензии CeCILL-C 1.0 с ограниченной гарантией и ответственностью. Подробности — в тексте лицензии."); + color: WorkbenchStyle.muted; font-size: 13px; wrap: word-wrap; + font-family: "CrossDesk Message Spacing"; + } + Text { + text: "(c) Copyright 2009 - 2012 INRIA - All rights reserved"; + color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; + font-family: "CrossDesk Message Spacing"; + } + if root.openfec-index >= 0: AboutNavigationRow { + title: GalleryStrings.tr("OpenFEC 许可与源码", "OpenFEC license and source", "Лицензия и исходный код OpenFEC"); + clicked => { root.component-index = root.openfec-index; root.page = AboutPage.component; } + } + } + } + for component[index] in root.components: VerticalLayout { + padding: 0px; spacing: 0px; + if component.id != "crossdesk" && component.id != "openfec": AboutNavigationRow { + title: component.name; + subtitle: component.version + " · " + component.license; + clicked => { root.component-index = index; root.page = AboutPage.component; } + } + } + } + if root.page == AboutPage.component: VerticalLayout { + padding: 0px; spacing: 12px; + Text { text: root.selected.version + " · " + root.selected.license; color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; } + AboutNavigationRow { + title: AboutStrings.view-source; external: true; + clicked => { root.open-link(root.selected.source-url); } + } + Text { text: AboutStrings.notices; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; } + for document[index] in root.selected.documents: AboutNavigationRow { + title: document.title; + clicked => { root.document-index = index; root.page = AboutPage.document; } + } + if root.selected.documents.length == 0: Text { + text: GalleryStrings.tr("本组件未内置许可文本,请通过“查看源码”查阅许可与版权声明。", "License text is not bundled for this component. Use View source code to read its license and copyright notices.", "Текст лицензии не включён. Откройте исходный код для просмотра лицензии и авторских прав."); + color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; + } + } + if root.page == AboutPage.source: VerticalLayout { + padding: 0px; spacing: 12px; + Text { + text: root.revision != "" ? GalleryStrings.tr("源码版本:", "Source revision: ", "Ревизия исходного кода: ") + root.revision + : GalleryStrings.tr("本版本源码信息暂不可用。", "Source revision is unavailable for this build.", "Ревизия исходного кода этой сборки недоступна."); + color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap; + } + AboutNavigationRow { title: AboutStrings.view-source; external: true; clicked => { root.open-link(root.source-url); } } + AboutNavigationRow { + title: GalleryStrings.tr("构建说明", "Build instructions", "Инструкции по сборке"); external: true; + clicked => { root.open-link(root.build-url); } + } + AboutNavigationRow { + title: GalleryStrings.tr("项目主页", "Project website", "Сайт проекта"); external: true; + clicked => { root.open-link("https://crossdesk.cn"); } + } + } + } + } + if root.page == AboutPage.document: VerticalLayout { + x: 24px; y: root.content-top; width: parent.width - 48px; + height: footer.y - self.y - 16px; spacing: 12px; + Text { text: root.selected.documents[root.document-index].title; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; wrap: word-wrap; } + TextEdit { + text: root.selected.documents[root.document-index].text; + read-only: true; wrap: word-wrap; font-size: 14px; + // Use the same 1.5em line spacing as other long-form reading views. + font-family: "CrossDesk Message Spacing"; + vertical-stretch: 1; + key-pressed(event) => { + if event.text == Key.Escape { root.back(); return accept; } + return reject; + } + } + } + footer := VerticalLayout { + x: 24px; y: parent.height - self.height - 24px; + width: parent.width - 48px; height: self.preferred-height; spacing: 12px; + Rectangle { height: 1px; background: WorkbenchStyle.separator; } + HorizontalLayout { + alignment: end; + SettingsButton { text: AboutStrings.back; clicked => { root.back(); } } + } + } +} diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index ba768510..b32bdd22 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -32,9 +32,11 @@ export global FontAwesomeIcons { out property angle-right: "\u{f105}"; out property angle-up: "\u{f106}"; out property arrow-right-long: "\u{f178}"; + out property arrow-up-right-from-square: "\u{f35d}"; out property bars: "\u{f0c9}"; out property check: "\u{f00c}"; out property circle-arrow-up: "\u{f0aa}"; + out property code: "\u{f121}"; out property compress: "\u{f066}"; out property computer-mouse: "\u{f8cc}"; out property copy: "\u{f0c5}"; @@ -42,8 +44,10 @@ export global FontAwesomeIcons { out property expand: "\u{f065}"; out property eye: "\u{f06e}"; out property eye-slash: "\u{f070}"; + out property file-lines: "\u{f15c}"; out property folder-open: "\u{f07c}"; out property keyboard: "\u{f11c}"; + out property layer-group: "\u{f5fd}"; out property minus: "\u{f068}"; out property pen: "\u{f304}"; out property rotate: "\u{f2f1}"; diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index 48c95277..1f5401a9 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 { 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"; export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint"; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 9b4edb31..b1671c54 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -1,3 +1,4 @@ +import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint"; import { UiRadius } from "ui_radius.slint"; import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint"; import { GalleryStrings } from "device_strings.slint"; @@ -536,6 +537,20 @@ export component MainWindow inherits Window { if !root.self-host-settings-open { root.self-host-settings-error = ""; } } in-out property about-open: false; + in-out property about-page: overview; + private property about-scroll-y: 0px; + in-out property about-component-index: 0; + in property <[AboutComponent]> about-components; + in property about-openfec-index: -1; + in property about-revision; + in property about-source-url; + in property about-build-url; + callback open-about-link(string); + 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; } + else { root.about-page = AboutPage.overview; } + } in-out property sponsorship-open: false; // Automatic prompts must not interrupt another dialog or menu. private property sponsorship-unobstructed: !root.gallery-mode @@ -627,6 +642,15 @@ export component MainWindow inherits Window { changed about-open => { if root.about-open { root.dialog-focus-pending = true; } + else { + root.about-page = AboutPage.overview; + root.about-component-index = 0; + root.about-scroll-y = 0px; + } + } + changed about-page => { + // Restore dialog shortcuts after leaving the selectable license text. + if root.about-open { root.dialog-focus-pending = true; } } changed sponsorship-open => { @@ -855,7 +879,10 @@ export component MainWindow inherits Window { return accept; } if root.about-open && !root.update-open { - if event.text == Key.Escape { root.about-open = false; } + if event.text == Key.Escape { + if root.about-page != AboutPage.overview { root.about-back(); } + else { root.about-open = false; } + } return accept; } if root.sponsorship-open && root.sponsorship-unobstructed { @@ -2365,17 +2392,36 @@ export component MainWindow inherits Window { } if root.about-open: Rectangle { + // Use the same bounds throughout the About navigation stack. + private property dialog-width: min(480px, self.width - 40px); + private property dialog-height: min(520px, self.height - 32px); background: #15223850; z: 46; TouchArea { clicked => { focus-sink.focus(); } } - SettingsDialog { + if root.about-page != AboutPage.overview: AboutPanel { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - width: min(480px, parent.width - 40px); + width: parent.dialog-width; + min-height: 0px; max-height: parent.height - 32px; + height: parent.dialog-height; + page <=> root.about-page; + component-index <=> root.about-component-index; + components: root.about-components; + openfec-index: root.about-openfec-index; + revision: root.about-revision; + source-url: root.about-source-url; + build-url: root.about-build-url; + back => { root.about-back(); } + open-link(url) => { root.open-about-link(url); } + } + if root.about-page == AboutPage.overview: SettingsDialog { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: parent.dialog-width; // The overlay excludes any custom titlebar on every platform. min-height: 0px; max-height: parent.height - 32px; // Keep the dialog stable as update status and release details change. - height: min(488px, parent.height - 32px); + height: parent.dialog-height; title: UiStrings.about; close-label: MessageStrings.close; close => { root.about-open = false; } @@ -2388,6 +2434,8 @@ export component MainWindow inherits Window { height: about-footer.y - self.y - 16px; viewport-width: self.visible-width; viewport-height: about-content.preferred-height; + // Keep the position when navigation recreates the overview. + viewport-y <=> root.about-scroll-y; horizontal-scrollbar-policy: always-off; vertical-scrollbar-policy: as-needed; about-content := VerticalLayout { @@ -2465,6 +2513,26 @@ export component MainWindow inherits Window { Text { text: UiStrings.copyright; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; } Text { text: UiStrings.license; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; } } + AboutNavigationGroup { + AboutNavigationRow { + grouped: true; icon: FontAwesomeIcons.file-lines; + title: AboutStrings.software-license; + detail: "GPL v3"; + clicked => { root.about-component-index = 0; root.about-page = AboutPage.component; } + } + AboutRowSeparator { } + AboutNavigationRow { + grouped: true; icon: FontAwesomeIcons.layer-group; + title: AboutStrings.components; + clicked => { root.about-page = AboutPage.components; } + } + AboutRowSeparator { } + AboutNavigationRow { + grouped: true; icon: FontAwesomeIcons.code; + title: AboutStrings.source; + clicked => { root.about-page = AboutPage.source; } + } + } } } about-footer := VerticalLayout { @@ -2472,17 +2540,14 @@ export component MainWindow inherits Window { width: parent.width - 48px; height: self.preferred-height; spacing: 16px; Rectangle { height: 1px; background: WorkbenchStyle.separator; } + if root.update-status-text != "": Text { + text: root.update-status-text; + color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e + : root.update-check-status == UpdateCheckStatus.up-to-date ? WorkbenchStyle.success : WorkbenchStyle.muted; + font-size: 12px; wrap: word-wrap; + } HorizontalLayout { alignment: end; spacing: 8px; - // Status updates use the spare footer space without reflowing the content. - Text { - min-width: 0px; height: 32px; horizontal-stretch: 1; - text: root.update-status-text; - color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e - : root.update-check-status == UpdateCheckStatus.up-to-date ? WorkbenchStyle.success : WorkbenchStyle.muted; - font-size: 12px; wrap: word-wrap; overflow: elide; - vertical-alignment: center; - } if root.update-available: WorkbenchButton { width: max(80px, about-update-button-metrics.preferred-width + 24px); height: 32px; diff --git a/apps/desktop/xmake/rules/about_resources.lua b/apps/desktop/xmake/rules/about_resources.lua new file mode 100644 index 00000000..a3849244 --- /dev/null +++ b/apps/desktop/xmake/rules/about_resources.lua @@ -0,0 +1,134 @@ +-- Embed notices so the About pages also work in offline/portable installations. +rule("crossdesk.about_resources") + after_load(function(target) + target:add("includedirs", path.join(target:autogendir(), "about")) + end) + + before_build(function(target) + import("core.base.json") + import("core.project.config") + + local root = os.projectdir() + local function read(relative) + return assert(io.readfile(path.join(root, relative))) + end + local function git(directory, args) + local result + try {function() result = os.iorunv("git", table.join({"-C", directory}, args)) end} + return result and result:trim() or "" + end + local repository = "https://github.com/kunkundi/crossdesk" + local revision = git(root, {"rev-parse", "HEAD"}) + local source = repository .. (revision ~= "" and "/tree/" .. revision or "") + local mini_root = path.join(root, "deps/submodules/minirtc") + local mini_revision = git(mini_root, {"rev-parse", "HEAD"}) + local mini_source = "https://github.com/kunkundi/minirtc" .. + (mini_revision ~= "" and "/tree/" .. mini_revision or "") + local catalog = json.decode(read("apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json")) + local known = {} + for _, component in ipairs(catalog.components) do known[component.id] = component end + + -- These desktop dependencies are not part of the shared mobile catalog. + local desktop = { + slint = {"Slint", "https://github.com/slint-ui/slint/tree/v"}, + libsdl3 = {"SDL", "https://github.com/libsdl-org/SDL/tree/release-"}, + tinyfiledialogs = {"tinyfiledialogs", "https://sourceforge.net/projects/tinyfiledialogs/"}, + ["cpp-httplib"] = {"cpp-httplib", "https://github.com/yhirose/cpp-httplib/tree/v"}, + openh264 = {"OpenH264", "https://github.com/cisco/openh264/tree/v"}, + miniaudio = {"miniaudio", "https://github.com/mackron/miniaudio/tree/"}, + aom = {"libaom", "https://aomedia.googlesource.com/aom/+/v"} + } + local components = {{ + id = "crossdesk", name = "CrossDesk", version = config.get("CROSSDESK_VERSION") or "0.0.0", + license = "GPL-3.0-only", sourceURL = source, + documents = {{title = "GNU GPL v3", text = read("LICENSE")}} + }, { + id = "minirtc", name = "MiniRTC", version = mini_revision:sub(1, 12), + license = "LGPL-3.0-only", sourceURL = mini_source, + documents = {{title = "GNU LGPL v3", text = read("deps/submodules/minirtc/LICENSE")}, + {title = "GNU GPL v3", text = read("LICENSE")}} + }} + local seen = {} + for _, dependency in ipairs(table.join({target}, target:orderdeps())) do + for _, package in ipairs(dependency:orderpkgs()) do + local name = package:name() + if not seen[name] then + seen[name] = true + local version = package:get("version") or "" + local shared = known[name] + local extra = desktop[name] + local item = {id = name, name = shared and shared.name or extra and extra[1] or name, + version = version, license = package:license() or "", + sourceURL = "https://github.com/xmake-io/xmake-repo/tree/master/packages/" .. + name:sub(1, 1) .. "/" .. name, + documents = json.mark_as_array({})} + if shared and shared.version:gsub("^v", "") == version:gsub("^v", "") then + item.license = shared.license + item.sourceURL = shared.sourceURL + item.documents = shared.documents + elseif extra then + item.sourceURL = extra[2] .. (name == "tinyfiledialogs" and "" or version:gsub("^v", "")) + end + -- Prefer installed notices when a package is absent from the shared catalog. + if #item.documents == 0 then + local install = package:installdir() + if install then + for _, file in ipairs(os.files(path.join(install, "share/licenses/**"))) do + local text = io.readfile(file) + if text and not text:find("\0", 1, true) then + table.insert(item.documents, {title = path.relative(file, install), text = text}) + end + end + local notice_header = name == "tinyfiledialogs" and "include/tinyfiledialogs.h" + or name == "openh264" and "include/wels/codec_api.h" + if notice_header then + local header_path = path.join(install, notice_header) + local header = os.isfile(header_path) and io.readfile(header_path) + local notice = header and header:match("^/%*.-%*/") + if notice then + table.insert(item.documents, {title = notice_header, text = notice}) + end + end + end + end + if name == "slint" then + table.insert(item.documents, {title = "GNU GPL v3", text = read("LICENSE")}) + end + table.insert(components, item) + end + end + end + table.sort(components, function(a, b) + if a.id == b.id then return false end + if a.id == "crossdesk" then return true end + if b.id == "crossdesk" then return false end + return a.name:lower() < b.name:lower() + end) + local data = json.encode({components = components, revision = revision:sub(1, 12), + sourceURL = source, + buildURL = repository .. "/blob/" .. (revision ~= "" and revision or "main") .. "/docs/BUILD_EN.md"}, + {pure = true, orderkeys = true}) + -- A byte array avoids MSVC's limit on individual/concatenated string literals. + local bytes = {} + for index = 1, #data do + bytes[index] = tostring(data:byte(index)) .. (index % 32 == 0 and "\n" or "") + end + local output = [[/* + * @Author: DI JUNKUN + * @Date: 2026-10-21 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _ABOUT_RESOURCES_H_ +#define _ABOUT_RESOURCES_H_ + +// Generated by the desktop build. Do not edit. +inline constexpr unsigned char kAboutResources[] = { +]] .. table.concat(bytes, ",") .. "\n};\n\n#endif\n" + local destination = path.join(target:autogendir(), "about", "about_resources.h") + if not os.isfile(destination) or io.readfile(destination) ~= output then + os.mkdir(path.directory(destination)) + io.writefile(destination, output) + end + end) +rule_end() diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index d9c10837..b99a8940 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -560,6 +560,7 @@ function setup_targets() target("gui") set_kind("object") + add_rules("crossdesk.about_resources") set_languages("c++20") -- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by -- current Apple Clang in C++20 mode. Keep the established dependency @@ -585,6 +586,7 @@ function setup_targets() add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp", "apps/desktop/src/gui/rendering/*.cpp", "apps/desktop/src/gui/runtime/*.cpp", + "apps/desktop/src/gui/features/about/*.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", diff --git a/apps/harmony/scripts/build_desktop.py b/apps/harmony/scripts/build_desktop.py index 72041e09..f29ce3ad 100644 --- a/apps/harmony/scripts/build_desktop.py +++ b/apps/harmony/scripts/build_desktop.py @@ -43,6 +43,58 @@ def write_json(path, value): write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n') +def write_about_resources(output, version): + """Supply the embedded catalog required by the shared desktop About UI.""" + def git(directory, *args): + try: + return subprocess.check_output(['git', '-C', str(directory), *args], + text=True, stderr=subprocess.DEVNULL).strip() + except (OSError, subprocess.CalledProcessError): + return '' + + revision = git(ROOT, 'rev-parse', 'HEAD') + mini = git(ROOT / 'deps/submodules/minirtc', 'rev-parse', 'HEAD') + repository = 'https://github.com/kunkundi/crossdesk' + source = repository + (f'/tree/{revision}' if revision else '') + gpl = {'title': 'GNU GPL v3', 'text': (ROOT / 'LICENSE').read_text()} + components = [ + {'id': 'crossdesk', 'name': 'CrossDesk', 'version': version, + 'license': 'GPL-3.0-only', 'sourceURL': source, 'documents': [gpl]}, + {'id': 'minirtc', 'name': 'MiniRTC', 'version': mini[:12], + 'license': 'LGPL-3.0-only', + 'sourceURL': 'https://github.com/kunkundi/minirtc' + (f'/tree/{mini}' if mini else ''), + 'documents': [{'title': 'GNU LGPL v3', + 'text': (ROOT / 'deps/submodules/minirtc/LICENSE').read_text()}, gpl]}, + {'id': 'slint', 'name': 'Slint', 'version': '1.17.1', 'license': 'GPL-3.0-only', + 'sourceURL': 'https://github.com/slint-ui/slint/tree/v1.17.1', + 'documents': [{'title': 'Slint license', 'text': (SOURCE / 'LICENSE.md').read_text()}, gpl]}, + {'id': 'cpp-httplib', 'name': 'cpp-httplib', 'version': 'v0.26.0', 'license': 'MIT', + 'sourceURL': 'https://github.com/yhirose/cpp-httplib/tree/v0.26.0', 'documents': []}] + shared = json.loads((ROOT / 'apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json').read_text())['components'] + shared += json.loads((ROOT / 'apps/android/licenses/additional-notices.json').read_text()) + seen = {item['id'] for item in components} + for item in shared: + name = item['id'] + package = APP / '.native/packages' / name[0] / name + if name not in seen and any((package / tag).is_dir() for tag in + [item['version'], item['version'].removeprefix('v'), + 'v' + item['version'].removeprefix('v')]): + components.append(item) + seen.add(name) + components.sort(key=lambda item: (item['id'] != 'crossdesk', item['name'].lower())) + data = json.dumps({'components': components, 'revision': revision[:12], + 'sourceURL': source, + 'buildURL': f'{repository}/blob/{revision or "main"}/apps/harmony/README.md'}, + ensure_ascii=False, sort_keys=True).encode() + body = '\n'.join(','.join(str(byte) for byte in data[i:i + 32]) + ',' + for i in range(0, len(data), 32)) + write(output, '/*\n * @Author: DI JUNKUN\n * @Date: 2026-10-21\n' + ' * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.\n */\n\n' + '#ifndef _ABOUT_RESOURCES_H_\n#define _ABOUT_RESOURCES_H_\n\n' + '// Generated by the Harmony desktop build. Do not edit.\n' + 'inline constexpr unsigned char kAboutResources[] = {\n' + body + '\n};\n\n#endif\n') + + def main(): bundle = json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName'] parser = argparse.ArgumentParser(description=__doc__) @@ -157,6 +209,7 @@ def main(): urllib.request.urlretrieve('https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.26.0/httplib.h', header) if hashlib.sha256(header.read_bytes()).hexdigest() != '4a42da28ce477d06f53942c172197d3b83b4fe42b7e37b8af78c70e063c06da7': raise SystemExit('cpp-httplib source checksum mismatch') + write_about_resources(generated / 'about_resources.h', args.version) legacy_types = PROJECT / 'entry/src/main/cpp/types/libslint_probe' if legacy_types.exists(): run([studio / 'tools/hvigor/bin/hvigorw', 'clean', '--no-daemon'], cwd=PROJECT, env=env) diff --git a/xmake.lua b/xmake.lua index 36aea904..cd7c1fce 100644 --- a/xmake.lua +++ b/xmake.lua @@ -15,6 +15,7 @@ if desktop_platform then includes(path.join(source_dir, "apps/desktop/xmake/options.lua")) includes(path.join(source_dir, "apps/desktop/xmake/platform.lua")) includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua")) + includes(path.join(source_dir, "apps/desktop/xmake/rules/about_resources.lua")) includes(path.join(source_dir, "apps/desktop/xmake/targets.lua")) setup_options_and_dependencies()