From 829010824e3740f82419a8bd48282e77e92f0b78 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 03:11:51 +0800 Subject: [PATCH] Split macOS desktop pet helpers to keep the host under the size limit desktop_pet_mac.mm grew past the 1000-line R12 limit with the companion-pet work, failing layer-lint on master. Move the WebView bridge shim, environment/locale helpers, scale preference IO, coordinate conversion and resize cursor into desktop_pet_mac_support.hpp (included only by the .mm); no behaviour change. The browser tests now read the shim from the new header. --- src/apps/desktop/desktop_pet_mac.mm | 148 +--------------- src/apps/desktop/desktop_pet_mac_support.hpp | 171 +++++++++++++++++++ web/scripts/test-classic-desktop-pets.mjs | 2 +- web/scripts/test-desktop-office.mjs | 2 +- 4 files changed, 174 insertions(+), 149 deletions(-) create mode 100644 src/apps/desktop/desktop_pet_mac_support.hpp diff --git a/src/apps/desktop/desktop_pet_mac.mm b/src/apps/desktop/desktop_pet_mac.mm index 851dcfcb..e5d227a6 100644 --- a/src/apps/desktop/desktop_pet_mac.mm +++ b/src/apps/desktop/desktop_pet_mac.mm @@ -21,6 +21,7 @@ #include "web_host.hpp" #include "desktop_pet_layout.hpp" +#include "desktop_pet_mac_support.hpp" #include "desktop_office_service.hpp" #include "desktop_pet_resources.hpp" #include "version.hpp" @@ -73,153 +74,6 @@ enum MenuCommand : NSInteger { kMenuDock = 1, kMenuHide, kMenuZoomIn, kMenuZoomOut, kMenuResetSize, kMenuPin, kMenuPetKindBase = 100 /* + index into kDesktopPetKinds */ }; -// 页面以 WebView2 的接口写成;这里在文档开始前补一个同名实现。 -constexpr const char* kBridgeShim = R"JS((() => { - const listeners = []; - const handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.acePet; - if (!handler) return; - window.chrome = window.chrome || {}; - window.chrome.webview = { - postMessage: value => handler.postMessage(typeof value === 'string' ? value : JSON.stringify(value)), - addEventListener: (type, listener) => { if (type === 'message') listeners.push(listener); }, - removeEventListener: (type, listener) => { const i = listeners.indexOf(listener); if (i >= 0) listeners.splice(i, 1); }, - }; - window.__acePetDeliver = data => { for (const listener of listeners.slice()) { try { listener({ data }); } catch (e) {} } }; - // Keep the threshold-crossing move and release on the original element even - // when the pointer leaves the canvas before WebKit posts the drag request. - let captured = null; - let suppressClick = false; - document.addEventListener('pointerdown', event => { - if (event.button !== 0) return; - const target = event.target.closest?.('#cv, #grip'); - if (!target) return; - target.setPointerCapture(event.pointerId); - captured = { target, id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false }; - suppressClick = false; - // macOS owns the canvas threshold here. WebKit can omit screen coordinates; - // client coordinates suffice until the first native window movement. - if (target.id === 'cv') event.stopPropagation(); - }, true); - document.addEventListener('pointermove', event => { - if (captured?.id !== event.pointerId || captured.target.id !== 'cv') return; - event.stopPropagation(); - if (!(event.buttons & 1) || captured.dragged) return; - if (Math.abs(event.clientX - captured.x) + Math.abs(event.clientY - captured.y) < 4) return; - captured.dragged = true; - suppressClick = true; - handler.postMessage('drag'); - }, true); - document.addEventListener('pointerup', event => { - if (captured?.id === event.pointerId) captured = null; - }, true); - document.addEventListener('click', event => { - if (!suppressClick || event.target.id !== 'cv') return; - event.preventDefault(); - event.stopImmediatePropagation(); - suppressClick = false; - }, true); - const cancel = event => { - if (captured?.id !== event.pointerId) return; - captured = null; - suppressClick = true; - handler.postMessage('gesture-cancel'); - }; - document.addEventListener('pointercancel', cancel, true); - document.addEventListener('lostpointercapture', cancel, true); -})();)JS"; - -bool desktop_pet_disabled_by_env() { - const char* value = std::getenv("ACECODE_DESKTOP_PET"); - if (!value) return false; - const std::string text(value); - return text == "0" || text == "off" || text == "false" || text == "no"; -} - -bool chinese_ui() { - return native_locale().rfind("zh", 0) == 0; -} - -NSString* ns_string(const std::string& value) { - NSString* text = [[NSString alloc] initWithBytes:value.data() - length:value.size() - encoding:NSUTF8StringEncoding]; - return text ? text : @""; -} - -// 不放进 ACECode 数据目录:数据目录迁移会把持续写入的文件当成活文件。 -std::filesystem::path pet_root_dir() { - NSArray* urls = [[NSFileManager defaultManager] - URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask]; - NSURL* base = urls.firstObject; - const char* path = base ? base.fileSystemRepresentation : nullptr; - std::filesystem::path root = path ? std::filesystem::path(path) - : std::filesystem::temp_directory_path(); - return root / "ACECode" / "desktop-pet"; -} - -double load_logical_scale() { - std::ifstream in(pet_root_dir() / "settings.json"); - if (!in) return 0.0; - const auto json = nlohmann::json::parse(in, nullptr, false); - if (json.is_discarded() || !json.is_object() || !json.contains("scale") || !json["scale"].is_number()) return 0.0; - const double scale = json["scale"].get(); - return std::isfinite(scale) && scale > 0.0 ? scale : 0.0; -} - -void save_logical_scale(double scale) { - std::error_code ec; - std::filesystem::create_directories(pet_root_dir(), ec); - nlohmann::json json = nlohmann::json::object(); - if (scale > 0.0) json["scale"] = std::round(scale * 1000.0) / 1000.0; - std::ofstream out(pet_root_dir() / "settings.json", std::ios::trunc); - if (out) out << json.dump(); -} - -// 全局坐标翻转以主屏(菜单栏所在的屏)高度为准。 -CGFloat flip_height() { - NSScreen* primary = NSScreen.screens.firstObject; - return primary ? primary.frame.size.height : 0.0; -} - -DesktopPetRect top_left_rect(NSRect rect) { - return {static_cast(std::lround(rect.origin.x)), - static_cast(std::lround(flip_height() - (rect.origin.y + rect.size.height))), - static_cast(std::lround(rect.size.width)), - static_cast(std::lround(rect.size.height))}; -} - -NSRect cocoa_rect(const DesktopPetRect& rect) { - return NSMakeRect(rect.x, flip_height() - (rect.y + rect.height), rect.width, rect.height); -} - -NSCursor* make_resize_cursor() { - // Public AppKit APIs on every supported macOS version. A custom image avoids - // relying on WebKit's platform-dependent mapping of CSS nwse-resize. - NSImage* image = [NSImage imageWithSize:NSMakeSize(24, 24) flipped:NO - drawingHandler:^BOOL(NSRect rect) { - (void)rect; - NSBezierPath* path = [NSBezierPath bezierPath]; - [path moveToPoint:NSMakePoint(5, 19)]; - [path lineToPoint:NSMakePoint(19, 5)]; - [path moveToPoint:NSMakePoint(5, 12)]; - [path lineToPoint:NSMakePoint(5, 19)]; - [path lineToPoint:NSMakePoint(12, 19)]; - [path moveToPoint:NSMakePoint(12, 5)]; - [path lineToPoint:NSMakePoint(19, 5)]; - [path lineToPoint:NSMakePoint(19, 12)]; - path.lineJoinStyle = NSRoundLineJoinStyle; - path.lineCapStyle = NSRoundLineCapStyle; - [NSColor.whiteColor setStroke]; - path.lineWidth = 4; - [path stroke]; - [NSColor.blackColor setStroke]; - path.lineWidth = 2; - [path stroke]; - return YES; - }]; - return [[NSCursor alloc] initWithImage:image hotSpot:NSMakePoint(12, 12)]; -} - } // namespace // GUI 线程上的全部状态;ObjC 回调只持有它的 weak_ptr。 diff --git a/src/apps/desktop/desktop_pet_mac_support.hpp b/src/apps/desktop/desktop_pet_mac_support.hpp new file mode 100644 index 00000000..d8c60222 --- /dev/null +++ b/src/apps/desktop/desktop_pet_mac_support.hpp @@ -0,0 +1,171 @@ +#pragma once + +// macOS 桌宠宿主(desktop_pet_mac.mm)的辅助部分:注入页面的 chrome.webview 桥垫片、 +// 环境开关与界面语言、缩放偏好读写、Cocoa(左下原点)与左上原点坐标换算、缩放把手光标。 +// 只由 desktop_pet_mac.mm 包含(ObjC++,ARC);从那里拆出来是为了守住单文件 1000 行上限。 + +#include "desktop_pet_layout.hpp" +#include "platform/native_ui/strings.hpp" + +#import + +#include +#include +#include +#include +#include + +#include + +namespace acecode::desktop { +namespace { + +// 页面以 WebView2 的接口写成;这里在文档开始前补一个同名实现。 +constexpr const char* kBridgeShim = R"JS((() => { + const listeners = []; + const handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.acePet; + if (!handler) return; + window.chrome = window.chrome || {}; + window.chrome.webview = { + postMessage: value => handler.postMessage(typeof value === 'string' ? value : JSON.stringify(value)), + addEventListener: (type, listener) => { if (type === 'message') listeners.push(listener); }, + removeEventListener: (type, listener) => { const i = listeners.indexOf(listener); if (i >= 0) listeners.splice(i, 1); }, + }; + window.__acePetDeliver = data => { for (const listener of listeners.slice()) { try { listener({ data }); } catch (e) {} } }; + // Keep the threshold-crossing move and release on the original element even + // when the pointer leaves the canvas before WebKit posts the drag request. + let captured = null; + let suppressClick = false; + document.addEventListener('pointerdown', event => { + if (event.button !== 0) return; + const target = event.target.closest?.('#cv, #grip'); + if (!target) return; + target.setPointerCapture(event.pointerId); + captured = { target, id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false }; + suppressClick = false; + // macOS owns the canvas threshold here. WebKit can omit screen coordinates; + // client coordinates suffice until the first native window movement. + if (target.id === 'cv') event.stopPropagation(); + }, true); + document.addEventListener('pointermove', event => { + if (captured?.id !== event.pointerId || captured.target.id !== 'cv') return; + event.stopPropagation(); + if (!(event.buttons & 1) || captured.dragged) return; + if (Math.abs(event.clientX - captured.x) + Math.abs(event.clientY - captured.y) < 4) return; + captured.dragged = true; + suppressClick = true; + handler.postMessage('drag'); + }, true); + document.addEventListener('pointerup', event => { + if (captured?.id === event.pointerId) captured = null; + }, true); + document.addEventListener('click', event => { + if (!suppressClick || event.target.id !== 'cv') return; + event.preventDefault(); + event.stopImmediatePropagation(); + suppressClick = false; + }, true); + const cancel = event => { + if (captured?.id !== event.pointerId) return; + captured = null; + suppressClick = true; + handler.postMessage('gesture-cancel'); + }; + document.addEventListener('pointercancel', cancel, true); + document.addEventListener('lostpointercapture', cancel, true); +})();)JS"; + +bool desktop_pet_disabled_by_env() { + const char* value = std::getenv("ACECODE_DESKTOP_PET"); + if (!value) return false; + const std::string text(value); + return text == "0" || text == "off" || text == "false" || text == "no"; +} + +bool chinese_ui() { + return native_locale().rfind("zh", 0) == 0; +} + +NSString* ns_string(const std::string& value) { + NSString* text = [[NSString alloc] initWithBytes:value.data() + length:value.size() + encoding:NSUTF8StringEncoding]; + return text ? text : @""; +} + +// 不放进 ACECode 数据目录:数据目录迁移会把持续写入的文件当成活文件。 +std::filesystem::path pet_root_dir() { + NSArray* urls = [[NSFileManager defaultManager] + URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask]; + NSURL* base = urls.firstObject; + const char* path = base ? base.fileSystemRepresentation : nullptr; + std::filesystem::path root = path ? std::filesystem::path(path) + : std::filesystem::temp_directory_path(); + return root / "ACECode" / "desktop-pet"; +} + +double load_logical_scale() { + std::ifstream in(pet_root_dir() / "settings.json"); + if (!in) return 0.0; + const auto json = nlohmann::json::parse(in, nullptr, false); + if (json.is_discarded() || !json.is_object() || !json.contains("scale") || !json["scale"].is_number()) return 0.0; + const double scale = json["scale"].get(); + return std::isfinite(scale) && scale > 0.0 ? scale : 0.0; +} + +void save_logical_scale(double scale) { + std::error_code ec; + std::filesystem::create_directories(pet_root_dir(), ec); + nlohmann::json json = nlohmann::json::object(); + if (scale > 0.0) json["scale"] = std::round(scale * 1000.0) / 1000.0; + std::ofstream out(pet_root_dir() / "settings.json", std::ios::trunc); + if (out) out << json.dump(); +} + +// 全局坐标翻转以主屏(菜单栏所在的屏)高度为准。 +CGFloat flip_height() { + NSScreen* primary = NSScreen.screens.firstObject; + return primary ? primary.frame.size.height : 0.0; +} + +DesktopPetRect top_left_rect(NSRect rect) { + return {static_cast(std::lround(rect.origin.x)), + static_cast(std::lround(flip_height() - (rect.origin.y + rect.size.height))), + static_cast(std::lround(rect.size.width)), + static_cast(std::lround(rect.size.height))}; +} + +NSRect cocoa_rect(const DesktopPetRect& rect) { + return NSMakeRect(rect.x, flip_height() - (rect.y + rect.height), rect.width, rect.height); +} + +NSCursor* make_resize_cursor() { + // Public AppKit APIs on every supported macOS version. A custom image avoids + // relying on WebKit's platform-dependent mapping of CSS nwse-resize. + NSImage* image = [NSImage imageWithSize:NSMakeSize(24, 24) flipped:NO + drawingHandler:^BOOL(NSRect rect) { + (void)rect; + NSBezierPath* path = [NSBezierPath bezierPath]; + [path moveToPoint:NSMakePoint(5, 19)]; + [path lineToPoint:NSMakePoint(19, 5)]; + [path moveToPoint:NSMakePoint(5, 12)]; + [path lineToPoint:NSMakePoint(5, 19)]; + [path lineToPoint:NSMakePoint(12, 19)]; + [path moveToPoint:NSMakePoint(12, 5)]; + [path lineToPoint:NSMakePoint(19, 5)]; + [path lineToPoint:NSMakePoint(19, 12)]; + path.lineJoinStyle = NSRoundLineJoinStyle; + path.lineCapStyle = NSRoundLineCapStyle; + [NSColor.whiteColor setStroke]; + path.lineWidth = 4; + [path stroke]; + [NSColor.blackColor setStroke]; + path.lineWidth = 2; + [path stroke]; + return YES; + }]; + return [[NSCursor alloc] initWithImage:image hotSpot:NSMakePoint(12, 12)]; +} + +} // namespace +} // namespace acecode::desktop diff --git a/web/scripts/test-classic-desktop-pets.mjs b/web/scripts/test-classic-desktop-pets.mjs index 5f783462..bf11f051 100644 --- a/web/scripts/test-classic-desktop-pets.mjs +++ b/web/scripts/test-classic-desktop-pets.mjs @@ -47,7 +47,7 @@ try { const page = await browser.newPage({ viewport: { width: 430, height: 315 } }); page.on('pageerror', e => errors.push(e.message)); page.on('request', r => { if (/^https?:/.test(r.url())) external.push(r.url()); }); - const shim = mac ? fs.readFileSync(path.join(root, 'src/apps/desktop/desktop_pet_mac.mm'), 'utf8') + const shim = mac ? fs.readFileSync(path.join(root, 'src/apps/desktop/desktop_pet_mac_support.hpp'), 'utf8') .match(/kBridgeShim = R"JS\(([\s\S]*?)\)JS";/)?.[1] : ''; if (mac) assert.ok(shim); await page.addInitScript({ content: `window.actions=[]; diff --git a/web/scripts/test-desktop-office.mjs b/web/scripts/test-desktop-office.mjs index 7e8a90a4..c05f22b0 100644 --- a/web/scripts/test-desktop-office.mjs +++ b/web/scripts/test-desktop-office.mjs @@ -25,7 +25,7 @@ try { const page=await browser.newPage({viewport:{width:688,height:504},deviceScaleFactor:1}); page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>{if(/^https?:/.test(r.url()))external.push(r.url());}); if (macHost) { - const source=fs.readFileSync(path.join(root,'src/apps/desktop/desktop_pet_mac.mm'),'utf8'); + const source=fs.readFileSync(path.join(root,'src/apps/desktop/desktop_pet_mac_support.hpp'),'utf8'); const shim=source.match(/kBridgeShim = R"JS\(([\s\S]*?)\)JS";/)?.[1]; assert.ok(shim,'load the production macOS bridge'); await page.addInitScript({content:`