[{"data":1,"prerenderedAt":4205},["ShallowReactive",2],{"post-wails-v3-upgrade":3,"post-nav-posts":3700,"post-project-wails-v3-upgrade":3724},{"id":4,"title":5,"body":6,"cover":3687,"date":3688,"description":403,"extension":3689,"links":3687,"meta":3690,"navigation":658,"path":3691,"project":3692,"seo":3693,"slug":3694,"stem":3695,"summary":3696,"tags":3697,"visible":658,"__hash__":3699},"posts\u002Fposts\u002Fwails-v3-upgrade.md","Wails v3 升级实战：桌面提醒客户端从 v2 升到 v3，踩了 10 个不报错的坑",{"type":7,"value":8,"toc":3624},"minimark",[9,42,46,51,54,57,60,140,143,146,150,154,159,162,166,173,263,267,293,299,305,311,315,321,327,333,342,345,352,354,358,361,365,372,376,379,385,388,391,394,404,410,416,419,422,427,434,437,443,448,459,462,465,501,504,506,510,513,517,523,605,611,707,713,730,741,745,760,764,799,802,822,830,833,842,884,892,942,954,999,1009,1013,1018,1022,1038,1041,1050,1054,1062,1074,1085,1088,1286,1288,1292,1298,1301,1308,1311,1315,1343,1349,1356,1361,1365,1402,1406,1409,1495,1502,1506,1547,1557,1563,1567,1570,1573,1594,1600,1604,1637,1647,1651,1654,1657,1773,1778,1781,1783,1787,1790,1797,1801,1804,1811,1886,1895,1898,1913,1918,1921,1925,1928,1931,1995,2000,2003,2058,2061,2064,2158,2171,2181,2185,2192,2198,2204,2269,2280,2283,2287,2290,2336,2343,2347,2350,2378,2381,2542,2547,2551,2558,2580,2587,2594,2603,2624,2640,2643,2649,2659,2667,2677,2680,2699,2703,2709,2716,2726,2730,2743,2758,2761,2763,2767,2770,2777,2786,2789,2809,2812,2839,2848,2852,2855,2858,2918,2924,2927,2943,2950,2955,2990,2994,2997,3004,3009,3095,3098,3117,3120,3175,3184,3190,3194,3197,3202,3270,3275,3285,3291,3300,3310,3313,3320,3326,3330,3336,3342,3356,3433,3438,3445,3452,3455,3458,3481,3487,3489,3493,3496,3522,3525,3536,3540,3546,3559,3562,3569,3580,3582,3585,3608,3610,3620],[10,11,12,27],"blockquote",{},[13,14,15,16,21,22,26],"p",{},"姊妹篇：",[17,18,20],"a",{"href":19},"\u002Fblog\u002Fecom-detail-ai-part2-wails","《Wails v2 实战：用 Go + Vue3 做一个真正能用的 AI 桌面应用》"," 讲的是「第一次用 Wails 做桌面端」——为什么不用 Electron、绑定和事件怎么配合、SQLite 怎么当唯一真相。这篇讲另一个问题：",[23,24,25],"strong",{},"框架换代","。Wails 从 v2 升到 v3，哪些地方变了、为什么变、怎么迁、迁完踩了什么。",[13,28,29,30,34,35,41],{},"项目：",[17,31,33],{"href":32},"\u002Fprojects\u002Fmorrow","Morrow · 桌面健康提醒"," · ",[17,36,40],{"href":37,"rel":38},"https:\u002F\u002Fgithub.com\u002FBenny66\u002Fmorrow",[39],"nofollow","GitHub","。技术栈 Go 1.25 + Vue 3 + SQLite，Wails 从 v2.15.0 升到 v3.0.0-beta.23。",[43,44,45],"h2",{"id":45},"先说这是个什么项目",[13,47,48],{},[23,49,50],{},"Morrow 是一个桌面提醒客户端。",[13,52,53],{},"它干的事很朴素：你在电脑前坐久了，它提醒你喝水、起来走走、看一眼远处。不是又一个手机 App，是装在你 Mac \u002F Windows \u002F Linux 上的一个桌面程序。",[13,55,56],{},"它有点特别的地方是：提醒不是靠弹窗，而是靠一只叫「小满」的桌面宠物——一滴水，头顶长了株芽，你每完成一次喝水或起身，芽就长一点。",[13,58,59],{},"之所以拿它当例子讲 Wails，是因为它几乎把桌面端的\"麻烦需求\"踩了个遍：",[61,62,63,76],"table",{},[64,65,66],"thead",{},[67,68,69,73],"tr",{},[70,71,72],"th",{},"项",[70,74,75],{},"情况",[77,78,79,88,96,104,112,120],"tbody",{},[67,80,81,85],{},[82,83,84],"td",{},"形态",[82,86,87],{},"桌面客户端，单文件安装，没有服务端",[67,89,90,93],{},[82,91,92],{},"平台",[82,94,95],{},"macOS \u002F Windows \u002F Linux",[67,97,98,101],{},[82,99,100],{},"窗口",[82,102,103],{},"4 类：主窗、桌面宠物（透明、置顶、浮在所有窗口上）、全屏强提醒、副屏强提醒",[67,105,106,109],{},[82,107,108],{},"系统能力",[82,110,111],{},"托盘常驻、原生通知、全局快捷键、录屏时自动隐身、防止重复启动",[67,113,114,117],{},[82,115,116],{},"数据",[82,118,119],{},"全部存本地 SQLite，无账号、无云同步",[67,121,122,125],{},[82,123,124],{},"代码量",[82,126,127,131,132,135,136,139],{},[128,129,130],"code",{},"main.go"," 413 行，",[128,133,134],{},"app.go"," 2682 行，",[128,137,138],{},"internal\u002F"," 162 个 Go 文件",[13,141,142],{},"这些需求，Hello World 一个都碰不到，但真做一个能用的桌面软件，你一定会碰到。",[144,145],"hr",{},[43,147,149],{"id":148},"一wails-到底是个啥","一、Wails 到底是个啥",[151,152,153],"h3",{"id":153},"一句话说清",[13,155,156],{},[23,157,158],{},"Wails 就是让 Go 开一个原生窗口，窗口里装个浏览器，然后 Go 的函数可以直接被网页里的 JS 调用。",[13,160,161],{},"你写 Go 写后端，写 Vue \u002F React 写界面，Wails 把两边接起来，最后打成一个可执行文件。",[151,163,165],{"id":164},"和-electron-的区别","和 Electron 的区别",[13,167,168,169,172],{},"很多人一听\"用网页写桌面软件\"就想到 Electron。两者最大的不同是",[23,170,171],{},"浏览器从哪来","：",[61,174,175,190],{},[64,176,177],{},[67,178,179,181,184,187],{},[70,180],{},[70,182,183],{},"Electron",[70,185,186],{},"Wails",[70,188,189],{},"Tauri",[77,191,192,207,223,237,251],{},[67,193,194,197,200,205],{},[82,195,196],{},"浏览器",[82,198,199],{},"自己带一个 Chromium",[82,201,202],{},[23,203,204],{},"用系统自带的",[82,206,204],{},[67,208,209,212,215,220],{},[82,210,211],{},"后端语言",[82,213,214],{},"Node.js",[82,216,217],{},[23,218,219],{},"Go",[82,221,222],{},"Rust",[67,224,225,228,231,234],{},[82,226,227],{},"安装包大小",[82,229,230],{},"上百 MB",[82,232,233],{},"十几 MB",[82,235,236],{},"几 MB",[67,238,239,242,245,248],{},[82,240,241],{},"内存占用",[82,243,244],{},"高（Chromium 进程多）",[82,246,247],{},"中",[82,249,250],{},"低",[67,252,253,256,259,261],{},[82,254,255],{},"前端技术",[82,257,258],{},"什么都能用",[82,260,258],{},[82,262,258],{},[151,264,266],{"id":265},"优点我真实感受到的","优点（我真实感受到的）",[13,268,269,272,273,276,277,280,281,284,285,288,289,292],{},[23,270,271],{},"1. 就一个文件，不用装运行时。","\n前端打包出来的 ",[128,274,275],{},"dist"," 用 ",[128,278,279],{},"\u002F\u002Fgo:embed"," 塞进 Go 二进制里。发给用户就是一个 ",[128,282,283],{},".app"," 或者 ",[128,286,287],{},".exe","，没有 ",[128,290,291],{},"node_modules","，没有额外依赖。",[13,294,295,298],{},[23,296,297],{},"2. 后端就是 Go，不用写中间层。","\n想读个文件、连个数据库，直接写 Go。不像 Electron 那样，主进程和渲染进程之间要来回发消息，写一堆 IPC。",[13,300,301,304],{},[23,302,303],{},"3. 该有的系统能力都有。","\n托盘、通知、多窗口、全局快捷键、单实例锁——做桌面软件要用的东西，v3 基本齐了。",[13,306,307,310],{},[23,308,309],{},"4. 跨平台是真的跨。","\nmacOS 用系统 WebView，Windows 用 WebView2，Linux 用 WebKitGTK，一套代码三处跑。",[151,312,314],{"id":313},"缺点也得说清楚","缺点（也得说清楚）",[13,316,317,320],{},[23,318,319],{},"1. 浏览器版本不可控。","\n用户系统里的 WebView 可能是三年前的版本。你写的 CSS，在你自己机器上好好的，到用户那儿可能就错位了。Electron 没这个问题，因为它自带浏览器。",[13,322,323,326],{},[23,324,325],{},"2. 有些功能得走\"歪门邪道\"。","\n比如在 macOS 上让窗口真透明，得用苹果没公开的私有 API，还要在编译时加个特殊参数。这类事官方文档一句话带过，你得自己啃源码。",[13,328,329,332],{},[23,330,331],{},"3. 生态小，遇事只能自己查。","\nElectron 报个错，搜索引擎一堆答案。Wails 报个错，可能全网就你一个。",[13,334,335,338,339],{},[23,336,337],{},"4. 文档跟不上代码。","\n尤其 v3 还在 beta，文档经常落后源码半个版本。",[23,340,341],{},"最靠谱的资料来源是源码注释和 GitHub issue。",[13,343,344],{},"一句话总结：",[10,346,347],{},[13,348,349],{},[23,350,351],{},"Wails 轻、干净、够用，但它不是\"开箱即用\"的框架，是\"给你一副好骨架，血肉自己长\"。",[144,353],{},[43,355,357],{"id":356},"二我为什么要升级","二、我为什么要升级",[13,359,360],{},"这点得说实话，因为动机决定了后面所有取舍。",[151,362,364],{"id":363},"不是因为版本号更高","不是因为\"版本号更高\"",[13,366,367,368,371],{},"v3 在我升级那会儿还是 beta（",[128,369,370],{},"v3.0.0-beta.23","），生产项目上 beta 是有风险的。如果只是\"新版本更好看\"，我不会动。",[151,373,375],{"id":374},"真正的原因v3-有个-mcp","真正的原因：v3 有个 MCP",[13,377,378],{},"MCP 是让 AI 直接操作应用的一套协议。v3 把它内置了，意思是：",[13,380,381,384],{},[23,382,383],{},"AI 可以直接驱动你正在运行的这个桌面程序","——点按钮、读页面元素、调 Go 函数、等事件发生，全程不用人写一行测试代码。",[13,386,387],{},"这个能力对我意味着什么？",[13,389,390],{},"以前 AI 帮我改完代码，验证方式是：我人肉点界面、截图、看日志、告诉它\"行\"或\"不行\"。改十次，我就得点十次。",[13,392,393],{},"现在 AI 改完能自己验收：",[395,396,401],"pre",{"className":397,"code":399,"language":400},[398],"language-text","AI 改代码 → 重新构建 → 启动应用 → 调 Go 方法 → 读页面 → 断言成功\n","text",[128,402,399],{"__ignoreMap":403},"",[13,405,406,409],{},[23,407,408],{},"这是\"能力从无到有\"，不是\"效率提高一点\"。"," 而 MCP 只在 v3 里有，v2 永远不会加回来。",[13,411,412,413],{},"所以升级的理由只有一条：",[23,414,415],{},"等稳定版 = 无限期搁置这件事。",[151,417,418],{"id":418},"顺带解决两个老问题",[13,420,421],{},"除了 MCP，v3 还顺手解决了 v2 时代两个憋屈的妥协：",[13,423,424],{},[23,425,426],{},"1. macOS 上终于有系统托盘了。",[13,428,429,430,433],{},"v2 时代 macOS 没有托盘，原因很技术：Wails 自己占着 macOS 的 ",[128,431,432],{},"NSApplication"," delegate（一个全局回调入口），而所有第三方的托盘库也要抢这个位置。两个抢一个，结果是编译期符号冲突、运行时崩溃。",[13,435,436],{},"项目当初的解决办法是：macOS 上直接不做托盘，另外还 fork 了一个托盘库。",[13,438,439,440],{},"v3 把托盘做进框架了，冲突从根上没了。于是：",[23,441,442],{},"一个 no-op 文件、一个 fork 依赖、一段\"为什么 macOS 不行\"的注释，全删了。",[13,444,445],{},[23,446,447],{},"2. 日志适配器可以删了。",[13,449,450,451,454,455,458],{},"v2 的日志接口是自定义的，项目写了个 ",[128,452,453],{},"WailsAdapter"," 手工桥接到标准日志库。v3 直接用 Go 标准的 ",[128,456,457],{},"*slog.Logger","，桥接层删掉。",[151,460,461],{"id":461},"升级的代价我怎么对冲",[13,463,464],{},"因为用的是 beta，我的做法是：",[466,467,468,485,495],"ul",{},[469,470,471,172,474,477,478,480,481,484],"li",{},[23,472,473],{},"版本钉死",[128,475,476],{},"go.mod"," 和依赖清单都写死 ",[128,479,370],{},"，不用 ",[128,482,483],{},"@latest","。",[469,486,487,490,491,494],{},[23,488,489],{},"靠编译兜不住的，靠实跑兜","：API 改名这类问题，",[128,492,493],{},"go build"," 会直接报错，不会漏。真正危险的是\"编译过了但行为变了\"——这类只能靠实际跑起来验证。",[469,496,497,500],{},[23,498,499],{},"把\"编译通过\"和\"实跑通过\"分成两道关卡","，不合并。",[13,502,503],{},"这条纪律后面救了我好几次。",[144,505],{},[43,507,509],{"id":508},"三v2-和-v3-到底差在哪","三、v2 和 v3 到底差在哪",[13,511,512],{},"差异不少，但真正影响你写代码的是这五处。",[151,514,516],{"id":515},"_1-启动方式一调到底-三步走","1. 启动方式：一调到底 → 三步走",[13,518,519,522],{},[23,520,521],{},"v2："," 一个函数搞定。",[395,524,528],{"className":525,"code":526,"language":527,"meta":403,"style":403},"language-go shiki shiki-themes github-dark github-light","wails.Run(&options.App{\n    Title:  \"Morrow\",\n    Width:  1024,\n    Bind:   []interface{}{app},\n})\n","go",[128,529,530,562,575,587,599],{"__ignoreMap":403},[531,532,535,539,543,546,550,553,556,559],"span",{"class":533,"line":534},"line",1,[531,536,538],{"class":537},"shWlK","wails.",[531,540,542],{"class":541},"sqoU-","Run",[531,544,545],{"class":537},"(",[531,547,549],{"class":548},"spKkM","&",[531,551,552],{"class":541},"options",[531,554,555],{"class":537},".",[531,557,558],{"class":541},"App",[531,560,561],{"class":537},"{\n",[531,563,565,568,572],{"class":533,"line":564},2,[531,566,567],{"class":537},"    Title:  ",[531,569,571],{"class":570},"skb7c","\"Morrow\"",[531,573,574],{"class":537},",\n",[531,576,578,581,585],{"class":533,"line":577},3,[531,579,580],{"class":537},"    Width:  ",[531,582,584],{"class":583},"sTU5a","1024",[531,586,574],{"class":537},[531,588,590,593,596],{"class":533,"line":589},4,[531,591,592],{"class":537},"    Bind:   []",[531,594,595],{"class":548},"interface",[531,597,598],{"class":537},"{}{app},\n",[531,600,602],{"class":533,"line":601},5,[531,603,604],{"class":537},"})\n",[13,606,607,610],{},[23,608,609],{},"v3："," 拆成三步。",[395,612,614],{"className":525,"code":613,"language":527,"meta":403,"style":403},"\u002F\u002F 第一步：建应用（服务、资源、日志、错误处理、单实例）\nwailsApp := application.New(application.Options{...})\n\n\u002F\u002F 第二步：建窗口\nwindow := wailsApp.Window.NewWithOptions(...)\n\n\u002F\u002F 第三步：跑起来\nwailsApp.Run()\n",[128,615,616,622,654,660,665,685,690,696],{"__ignoreMap":403},[531,617,618],{"class":533,"line":534},[531,619,621],{"class":620},"si27w","\u002F\u002F 第一步：建应用（服务、资源、日志、错误处理、单实例）\n",[531,623,624,627,630,633,636,638,641,643,646,649,652],{"class":533,"line":564},[531,625,626],{"class":537},"wailsApp ",[531,628,629],{"class":548},":=",[531,631,632],{"class":537}," application.",[531,634,635],{"class":541},"New",[531,637,545],{"class":537},[531,639,640],{"class":541},"application",[531,642,555],{"class":537},[531,644,645],{"class":541},"Options",[531,647,648],{"class":537},"{",[531,650,651],{"class":548},"...",[531,653,604],{"class":537},[531,655,656],{"class":533,"line":577},[531,657,659],{"emptyLinePlaceholder":658},true,"\n",[531,661,662],{"class":533,"line":589},[531,663,664],{"class":620},"\u002F\u002F 第二步：建窗口\n",[531,666,667,670,672,675,678,680,682],{"class":533,"line":601},[531,668,669],{"class":537},"window ",[531,671,629],{"class":548},[531,673,674],{"class":537}," wailsApp.Window.",[531,676,677],{"class":541},"NewWithOptions",[531,679,545],{"class":537},[531,681,651],{"class":548},[531,683,684],{"class":537},")\n",[531,686,688],{"class":533,"line":687},6,[531,689,659],{"emptyLinePlaceholder":658},[531,691,693],{"class":533,"line":692},7,[531,694,695],{"class":620},"\u002F\u002F 第三步：跑起来\n",[531,697,699,702,704],{"class":533,"line":698},8,[531,700,701],{"class":537},"wailsApp.",[531,703,542],{"class":541},[531,705,706],{"class":537},"()\n",[13,708,709,712],{},[23,710,711],{},"为什么这么改："," 因为窗口不再只有一个。v2 那个写法默认\"一个应用一个窗口\"，v3 要支持多窗口，就得把\"建应用\"和\"建窗口\"分开。",[13,714,715,718,719,722,723,725,726,729],{},[23,716,717],{},"带来一个坑："," 第三步 ",[128,720,721],{},"Run()"," 一进去就会回调所有服务的启动方法。所以你要在 ",[128,724,721],{}," ",[23,727,728],{},"之前","把应用实例注入给业务代码，否则启动方法里用到了就 nil panic——用户看到的是\"双击没反应\"。",[151,731,733,734,737,738],{"id":732},"_2-绑定bind-services","2. 绑定：",[128,735,736],{},"Bind"," → ",[128,739,740],{},"Services",[13,742,743],{},[23,744,521],{},[395,746,748],{"className":525,"code":747,"language":527,"meta":403,"style":403},"Bind: []interface{}{app}\n",[128,749,750],{"__ignoreMap":403},[531,751,752,755,757],{"class":533,"line":534},[531,753,754],{"class":537},"Bind: []",[531,756,595],{"class":548},[531,758,759],{"class":537},"{}{app}\n",[13,761,762],{},[23,763,609],{},[395,765,767],{"className":525,"code":766,"language":527,"meta":403,"style":403},"Services: []application.Service{\n    application.NewService(app),\n}\n",[128,768,769,783,794],{"__ignoreMap":403},[531,770,771,774,776,778,781],{"class":533,"line":534},[531,772,773],{"class":537},"Services: []",[531,775,640],{"class":541},[531,777,555],{"class":537},[531,779,780],{"class":541},"Service",[531,782,561],{"class":537},[531,784,785,788,791],{"class":533,"line":564},[531,786,787],{"class":537},"    application.",[531,789,790],{"class":541},"NewService",[531,792,793],{"class":537},"(app),\n",[531,795,796],{"class":533,"line":577},[531,797,798],{"class":537},"}\n",[13,800,801],{},"看起来只是换了个名字，其实语义变了：",[466,803,804,819],{},[469,805,806,807,810,811,814,815,818],{},"只有",[23,808,809],{},"导出的方法","会被暴露给前端，生命周期方法（",[128,812,813],{},"ServiceStartup"," \u002F ",[128,816,817],{},"ServiceShutdown","）会被框架自动排除；",[469,820,821],{},"服务有了生命周期概念，不用再自己找地方初始化。",[151,823,825,826,829],{"id":824},"_3-runtime-包没了能力长在对象上","3. ",[128,827,828],{},"runtime"," 包没了，能力长在对象上",[13,831,832],{},"这是 v3 最核心的变化。",[13,834,835,837,838,841],{},[23,836,521],{}," 启动时存一个 ",[128,839,840],{},"ctx","，之后干什么都把它传进去。",[395,843,845],{"className":525,"code":844,"language":527,"meta":403,"style":403},"runtime.WindowShow(ctx)\nruntime.EventsEmit(ctx, \"event\", data)\nruntime.MessageDialog(ctx, opts)\n",[128,846,847,858,874],{"__ignoreMap":403},[531,848,849,852,855],{"class":533,"line":534},[531,850,851],{"class":537},"runtime.",[531,853,854],{"class":541},"WindowShow",[531,856,857],{"class":537},"(ctx)\n",[531,859,860,862,865,868,871],{"class":533,"line":564},[531,861,851],{"class":537},[531,863,864],{"class":541},"EventsEmit",[531,866,867],{"class":537},"(ctx, ",[531,869,870],{"class":570},"\"event\"",[531,872,873],{"class":537},", data)\n",[531,875,876,878,881],{"class":533,"line":577},[531,877,851],{"class":537},[531,879,880],{"class":541},"MessageDialog",[531,882,883],{"class":537},"(ctx, opts)\n",[13,885,886,888,889,891],{},[23,887,609],{}," 没有 ",[128,890,828],{}," 了，能力挂在对象上。",[395,893,895],{"className":525,"code":894,"language":527,"meta":403,"style":403},"app.Window.GetByName(\"main\").Show()\napp.Event.Emit(\"event\", data)\napp.Dialog.OpenFile()\n",[128,896,897,918,932],{"__ignoreMap":403},[531,898,899,902,905,907,910,913,916],{"class":533,"line":534},[531,900,901],{"class":537},"app.Window.",[531,903,904],{"class":541},"GetByName",[531,906,545],{"class":537},[531,908,909],{"class":570},"\"main\"",[531,911,912],{"class":537},").",[531,914,915],{"class":541},"Show",[531,917,706],{"class":537},[531,919,920,923,926,928,930],{"class":533,"line":564},[531,921,922],{"class":537},"app.Event.",[531,924,925],{"class":541},"Emit",[531,927,545],{"class":537},[531,929,870],{"class":570},[531,931,873],{"class":537},[531,933,934,937,940],{"class":533,"line":577},[531,935,936],{"class":537},"app.Dialog.",[531,938,939],{"class":541},"OpenFile",[531,941,706],{"class":537},[13,943,944,945,947,948,950,951,172],{},"所以 ",[128,946,558],{}," 结构体里那个 ",[128,949,840],{}," 字段，换成了 ",[128,952,953],{},"*application.App",[395,955,957],{"className":525,"code":956,"language":527,"meta":403,"style":403},"type App struct {\n    app *application.App   \u002F\u002F v2 这里是 ctx context.Context\n    ...\n}\n",[128,958,959,973,990,995],{"__ignoreMap":403},[531,960,961,964,967,970],{"class":533,"line":534},[531,962,963],{"class":548},"type",[531,965,966],{"class":541}," App",[531,968,969],{"class":548}," struct",[531,971,972],{"class":537}," {\n",[531,974,975,978,981,983,985,987],{"class":533,"line":564},[531,976,977],{"class":537},"    app ",[531,979,980],{"class":548},"*",[531,982,640],{"class":541},[531,984,555],{"class":537},[531,986,558],{"class":541},[531,988,989],{"class":620},"   \u002F\u002F v2 这里是 ctx context.Context\n",[531,991,992],{"class":533,"line":577},[531,993,994],{"class":548},"    ...\n",[531,996,997],{"class":533,"line":589},[531,998,798],{"class":537},[13,1000,1001,1004,1005,1008],{},[23,1002,1003],{},"好处："," 依赖变显式了，能 mock，能分层。\n",[23,1006,1007],{},"代价："," 你得自己想清楚\"谁先创建、谁后创建\"。",[151,1010,1012],{"id":1011},"_4-日志从自定义接口-直接用标准库","4. 日志：从自定义接口 → 直接用标准库",[13,1014,1015,1017],{},[23,1016,521],{}," 自定义 logger 接口，项目为此写了适配器。",[13,1019,1020],{},[23,1021,609],{},[395,1023,1025],{"className":525,"code":1024,"language":527,"meta":403,"style":403},"Logger: slog.Default(),\n",[128,1026,1027],{"__ignoreMap":403},[531,1028,1029,1032,1035],{"class":533,"line":534},[531,1030,1031],{"class":537},"Logger: slog.",[531,1033,1034],{"class":541},"Default",[531,1036,1037],{"class":537},"(),\n",[13,1039,1040],{},"适配器删掉，代码变少。",[13,1042,1043,1046,1047,484],{},[23,1044,1045],{},"但这里埋了一个大坑","——后面第五部分会讲，production 构建下这个默认 logger ",[23,1048,1049],{},"是往黑洞里写的",[151,1051,1053],{"id":1052},"_5-构建从-cli-直调-taskfile-驱动","5. 构建：从 CLI 直调 → Taskfile 驱动",[13,1055,1056,725,1058,1061],{},[23,1057,521],{},[128,1059,1060],{},"wails build"," 一条命令，参数直接传。",[13,1063,1064,725,1066,1069,1070,1073],{},[23,1065,609],{},[128,1067,1068],{},"wails3 build"," 只是入口，真正的编译命令在 ",[128,1071,1072],{},"build\u002F\u003C平台>\u002FTaskfile.yml"," 里。",[13,1075,1076,1078,1079,725,1081,1084],{},[23,1077,1003],{}," 构建逻辑可读可改。比如给 macOS 加私有 API 参数，改 Taskfile 就行。\n",[23,1080,1007],{},[128,1082,1083],{},"-ldflags"," 不再转发（版本号注入要靠这个），得自己接一条链。",[151,1086,1087],{"id":1087},"一张表看完",[61,1089,1090,1105],{},[64,1091,1092],{},[67,1093,1094,1096,1099,1102],{},[70,1095,72],{},[70,1097,1098],{},"v2 怎么写",[70,1100,1101],{},"v3 怎么写",[70,1103,1104],{},"影响",[77,1106,1107,1128,1146,1168,1183,1201,1215,1232,1250,1268],{},[67,1108,1109,1112,1117,1125],{},[82,1110,1111],{},"启动",[82,1113,1114],{},[128,1115,1116],{},"wails.Run(&options.App{})",[82,1118,1119,737,1121,737,1123],{},[128,1120,635],{},[128,1122,677],{},[128,1124,542],{},[82,1126,1127],{},"得想清楚顺序",[67,1129,1130,1133,1138,1143],{},[82,1131,1132],{},"绑定",[82,1134,1135],{},[128,1136,1137],{},"Bind: []interface{}{}",[82,1139,1140],{},[128,1141,1142],{},"Services: []application.Service{}",[82,1144,1145],{},"语义变了",[67,1147,1148,1151,1156,1161],{},[82,1149,1150],{},"调框架",[82,1152,1153],{},[128,1154,1155],{},"runtime.Xxx(ctx, ...)",[82,1157,1158],{},[128,1159,1160],{},"app.Xxx()",[82,1162,1163,1165,1166],{},[128,1164,558],{}," 不再要 ",[128,1167,840],{},[67,1169,1170,1173,1176,1180],{},[82,1171,1172],{},"日志",[82,1174,1175],{},"自定义接口 + 适配器",[82,1177,1178],{},[128,1179,457],{},[82,1181,1182],{},"适配器删掉",[67,1184,1185,1188,1193,1198],{},[82,1186,1187],{},"错误",[82,1189,1190],{},[128,1191,1192],{},"ErrorFormatter func(error) any",[82,1194,1195],{},[128,1196,1197],{},"MarshalError func(error) []byte",[82,1199,1200],{},"前端不用改",[67,1202,1203,1206,1209,1212],{},[82,1204,1205],{},"托盘",[82,1207,1208],{},"第三方库，macOS 不行",[82,1210,1211],{},"内置，三平台都行",[82,1213,1214],{},"删依赖、删 no-op",[67,1216,1217,1220,1224,1229],{},[82,1218,1219],{},"构建",[82,1221,1222],{},[128,1223,1060],{},[82,1225,1226,1228],{},[128,1227,1068],{}," + Taskfile",[82,1230,1231],{},"参数不直传了",[67,1233,1234,1237,1242,1247],{},[82,1235,1236],{},"配置",[82,1238,1239],{},[128,1240,1241],{},"wails.json",[82,1243,1244],{},[128,1245,1246],{},"build\u002Fconfig.yml",[82,1248,1249],{},"文件名和格式都变了",[67,1251,1252,1255,1260,1265],{},[82,1253,1254],{},"单实例",[82,1256,1257],{},[128,1258,1259],{},"UniqueId",[82,1261,1262],{},[128,1263,1264],{},"UniqueID",[82,1266,1267],{},"大小写变了",[67,1269,1270,1273,1278,1283],{},[82,1271,1272],{},"绑定产物",[82,1274,1275],{},[128,1276,1277],{},"wailsjs\u002Fgo\u002Fmain\u002FApp",[82,1279,1280],{},[128,1281,1282],{},"frontend\u002Fbindings\u002F\u003C模块>\u002F",[82,1284,1285],{},"路径变了",[144,1287],{},[43,1289,1291],{"id":1290},"四怎么升级","四、怎么升级",[13,1293,1294,1295,484],{},"我分了 8 个阶段，每个阶段有明确的\"出口\"——",[23,1296,1297],{},"过了这个出口才能进下一步",[13,1299,1300],{},"关键纪律只有一条：",[10,1302,1303],{},[13,1304,1305],{},[23,1306,1307],{},"\"编译通过\"和\"实跑通过\"是两件事，不要合并。",[13,1309,1310],{},"因为 API 改名这种问题编译时就会报；但\"行为变了\"编译时完全看不出来。",[151,1312,1314],{"id":1313},"阶段-0准备别急着改代码","阶段 0：准备（别急着改代码）",[395,1316,1320],{"className":1317,"code":1318,"language":1319,"meta":403,"style":403},"language-bash shiki shiki-themes github-dark github-light","go install github.com\u002Fwailsapp\u002Fwails\u002Fv3\u002Fcmd\u002Fwails3@v3.0.0-beta.23\nwails3 version   # 必须是 beta.23，和 go.mod 对齐\n","bash",[128,1321,1322,1332],{"__ignoreMap":403},[531,1323,1324,1326,1329],{"class":533,"line":534},[531,1325,527],{"class":541},[531,1327,1328],{"class":570}," install",[531,1330,1331],{"class":570}," github.com\u002Fwailsapp\u002Fwails\u002Fv3\u002Fcmd\u002Fwails3@v3.0.0-beta.23\n",[531,1333,1334,1337,1340],{"class":533,"line":564},[531,1335,1336],{"class":541},"wails3",[531,1338,1339],{"class":570}," version",[531,1341,1342],{"class":620},"   # 必须是 beta.23，和 go.mod 对齐\n",[13,1344,1345,1346,484],{},"然后做一件很重要的事：",[23,1347,1348],{},"开个新分支，先写个最小 demo 跑通",[13,1350,1351,1352,1355],{},"我用 ",[128,1353,1354],{},"wails3 init"," 生成了一个空项目，把几个不确定的点都试了一遍：多窗口怎么建、托盘怎么加、关闭窗口怎么拦、单实例怎么配。",[13,1357,1358],{},[23,1359,1360],{},"这一步花了两小时，省了后面两天。",[151,1362,1364],{"id":1363},"阶段-1换依赖和配置","阶段 1：换依赖和配置",[466,1366,1367,1378,1381,1396],{},[469,1368,1369,172,1371,737,1374,1377],{},[128,1370,476],{},[128,1372,1373],{},"wails\u002Fv2",[128,1375,1376],{},"wails\u002Fv3","，版本钉死",[469,1379,1380],{},"删掉第三方的托盘库",[469,1382,1383,737,1385,1387,1388,1391,1392,1395],{},[128,1384,1241],{},[128,1386,1246],{},"（注意版本字段从 ",[128,1389,1390],{},"productVersion"," 改名成 ",[128,1393,1394],{},"version","，值是带引号的 YAML）",[469,1397,1398,1399],{},"加 ",[128,1400,1401],{},"Taskfile.yml",[151,1403,1405],{"id":1404},"阶段-2改-go-代码到这里先只求编译过","阶段 2：改 Go 代码（到这里先只求编译过）",[13,1407,1408],{},"按这个顺序改，因为依赖是从下往上的：",[1410,1411,1412,1417,1436,1446,1455,1470,1483,1492],"ol",{},[469,1413,1414,1416],{},[128,1415,130],{},"：启动改三步",[469,1418,1419,172,1421,1424,1425,1428,1429,1432,1433],{},[128,1420,130],{},[128,1422,1423],{},"Logger"," 换成 ",[128,1426,1427],{},"slog.Default()","，",[128,1430,1431],{},"ErrorFormatter"," 换 ",[128,1434,1435],{},"MarshalError",[469,1437,1438,1440,1441,1443,1444],{},[128,1439,134],{},"：删 ",[128,1442,840],{}," 字段，改持 ",[128,1445,953],{},[469,1447,1448,1450,1451,1454],{},[128,1449,134],{},"：所有 ",[128,1452,1453],{},"runtime.*"," 换成对象方法",[469,1456,1457,172,1460,1463,1464,1466,1467],{},[128,1458,1459],{},"internal\u002Fapperr",[128,1461,1462],{},"Format"," 改 ",[128,1465,1435],{},"，返回 ",[128,1468,1469],{},"[]byte",[469,1471,1472,1475,1476,1479,1480],{},[128,1473,1474],{},"internal\u002Fdialog","：改成持有 ",[128,1477,1478],{},"app","，调 ",[128,1481,1482],{},"app.Dialog.*",[469,1484,1485,1488,1489],{},[128,1486,1487],{},"internal\u002Ftray","：换成内置托盘，",[23,1490,1491],{},"删掉 macOS 的 no-op 分支",[469,1493,1494],{},"删掉日志适配器文件",[13,1496,1497,1498,1501],{},"出口：",[128,1499,1500],{},"go build .\u002F..."," 通过。",[151,1503,1505],{"id":1504},"阶段-3改前端","阶段 3：改前端",[1410,1507,1508,1518,1525,1538,1544],{},[469,1509,1510,1511,1514,1515],{},"跑 ",[128,1512,1513],{},"wails3 generate bindings","，生成 ",[128,1516,1517],{},"frontend\u002Fbindings\u002F",[469,1519,1520,1521,1524],{},"删掉旧的 ",[128,1522,1523],{},"frontend\u002Fwailsjs\u002F"," 目录",[469,1526,1527,1530,1531,1534,1535],{},[128,1528,1529],{},"lib\u002Fevent.ts","：事件订阅改成 ",[128,1532,1533],{},"Events.On","，载荷取 ",[128,1536,1537],{},"ev.data",[469,1539,1540,1543],{},[128,1541,1542],{},"lib\u002Flog.ts","：日志出口改成调 log 服务",[469,1545,1546],{},"所有 import 绑定路径的地方改路径",[13,1548,1497,1549,1552,1553,1556],{},[128,1550,1551],{},"vue-tsc --noEmit"," + ESLint + ",[128,1554,1555],{},"npm run build"," 全过。",[13,1558,1559,1562],{},[23,1560,1561],{},"注意这一步比想象的大。"," 我原以为只改 4 个文件，实际有 5 个文件 import 了旧路径，还有组件里调窗口 API 的地方因为同步变异步也要改。",[151,1564,1566],{"id":1565},"阶段-4改护栏","阶段 4：改护栏",[13,1568,1569],{},"如果你的项目有静态检查（我有一堆），这一步不能漏。",[13,1571,1572],{},"因为护栏是靠解析源码做断言的，框架一换，它们解析的目标全变了：",[466,1574,1575,1585],{},[469,1576,1577,1578,1581,1582],{},"原来找 ",[128,1579,1580],{},"options.App","，现在要找 ",[128,1583,1584],{},"application.Options",[469,1586,1587,1588,1591,1592],{},"原来看 ",[128,1589,1590],{},"wailsjs\u002F","，现在要看 ",[128,1593,1517],{},[13,1595,1596,1599],{},[23,1597,1598],{},"漏改的症状是护栏误报","，不是漏报——所以还算安全。但如果护栏写成\"找不到就跳过\"，那就变成永远绿了。",[151,1601,1603],{"id":1602},"阶段-5改构建和发布链","阶段 5：改构建和发布链",[1410,1605,1606,1614,1625,1631],{},[469,1607,1608,1611,1612],{},[128,1609,1610],{},"Makefile"," 切 ",[128,1613,1336],{},[469,1615,1616,172,1619],{},[128,1617,1618],{},"scripts\u002Fbuild.sh",[23,1620,1621,1622],{},"新增一步 ",[128,1623,1624],{},"wails3 task common:update:build-assets",[469,1626,1627,1628,1630],{},"版本注入改走 Taskfile 变量（",[128,1629,1083],{}," 不转发了）",[469,1632,1633,1634,1636],{},"CI 装 wails3 CLI，版本从 ",[128,1635,476],{}," 读",[13,1638,1639,1640,1643,1644],{},"第 2 条特别容易漏：v2 时代 ",[128,1641,1642],{},"Info.plist"," 是自动渲染的，v3 必须显式跑一条命令。",[23,1645,1646],{},"漏了的结果是：应用里显示新版本号，系统里显示旧版本号。",[151,1648,1650],{"id":1649},"阶段-6-7接新能力改文档","阶段 6-7：接新能力、改文档",[13,1652,1653],{},"到这里应用已经能跑了，剩下的是把 MCP 接上（如果你要用）、更新 README 和架构文档。",[151,1655,1656],{"id":1656},"我的实际节奏",[61,1658,1659,1672],{},[64,1660,1661],{},[67,1662,1663,1666,1669],{},[70,1664,1665],{},"阶段",[70,1667,1668],{},"干了什么",[70,1670,1671],{},"出口",[77,1673,1674,1685,1696,1709,1723,1737,1751,1762],{},[67,1675,1676,1679,1682],{},[82,1677,1678],{},"0",[82,1680,1681],{},"装 CLI、写 demo、验证 6 个不确定点",[82,1683,1684],{},"demo 能跑",[67,1686,1687,1690,1693],{},[82,1688,1689],{},"1",[82,1691,1692],{},"依赖 + 配置骨架",[82,1694,1695],{},"目录结构就位",[67,1697,1698,1701,1704],{},[82,1699,1700],{},"2",[82,1702,1703],{},"Go 代码迁移",[82,1705,1706,1708],{},[128,1707,493],{}," 通过",[67,1710,1711,1714,1717],{},[82,1712,1713],{},"3",[82,1715,1716],{},"前端迁移",[82,1718,1719,1722],{},[128,1720,1721],{},"vue-tsc"," + 构建通过",[67,1724,1725,1728,1731],{},[82,1726,1727],{},"4",[82,1729,1730],{},"护栏同步",[82,1732,1733,1736],{},[128,1734,1735],{},"make lint"," 全绿 + 破坏性验证",[67,1738,1739,1742,1745],{},[82,1740,1741],{},"5",[82,1743,1744],{},"构建链",[82,1746,1747,1750],{},[128,1748,1749],{},"make package"," 出安装包",[67,1752,1753,1756,1759],{},[82,1754,1755],{},"6",[82,1757,1758],{},"MCP 接线",[82,1760,1761],{},"Agent 能操作运行中的应用",[67,1763,1764,1767,1770],{},[82,1765,1766],{},"7",[82,1768,1769],{},"文档",[82,1771,1772],{},"README \u002F 架构图更新",[13,1774,1775],{},[23,1776,1777],{},"其中阶段 0 和阶段 4 是最容易被跳过、也最值得做的。",[13,1779,1780],{},"阶段 0 让你在干净环境里踩坑，不污染主分支。阶段 4 保证你那些静态检查在新框架下还真的在检查东西。",[144,1782],{},[43,1784,1786],{"id":1785},"五升级后踩的坑","五、升级后踩的坑",[13,1788,1789],{},"下面 10 个，按\"害我时间\"排序。共同点是：",[10,1791,1792],{},[13,1793,1794],{},[23,1795,1796],{},"不报错、不崩溃、日志干净。只是某个功能默默没了。",[151,1798,1800],{"id":1799},"坑-1production-构建下日志直接扔进黑洞最狠","坑 1：production 构建下，日志直接扔进黑洞（最狠）",[13,1802,1803],{},"这条我放在第一个，因为它能让你所有的排查手段全部失效。",[13,1805,1806,1807,1810],{},"v3 的默认 logger，在 ",[128,1808,1809],{},"production"," 构建标签下是这样的：",[395,1812,1814],{"className":525,"code":1813,"language":527,"meta":403,"style":403},"\u002F\u002F pkg\u002Fapplication\u002Flogger_prod.go\nfunc DefaultLogger(level slog.Leveler) *slog.Logger {\n    return slog.New(slog.NewTextHandler(io.Discard, nil))\n}\n",[128,1815,1816,1821,1857,1882],{"__ignoreMap":403},[531,1817,1818],{"class":533,"line":534},[531,1819,1820],{"class":620},"\u002F\u002F pkg\u002Fapplication\u002Flogger_prod.go\n",[531,1822,1823,1826,1829,1831,1835,1838,1840,1843,1846,1848,1851,1853,1855],{"class":533,"line":564},[531,1824,1825],{"class":548},"func",[531,1827,1828],{"class":541}," DefaultLogger",[531,1830,545],{"class":537},[531,1832,1834],{"class":1833},"sdxZB","level",[531,1836,1837],{"class":541}," slog",[531,1839,555],{"class":537},[531,1841,1842],{"class":541},"Leveler",[531,1844,1845],{"class":537},") ",[531,1847,980],{"class":548},[531,1849,1850],{"class":541},"slog",[531,1852,555],{"class":537},[531,1854,1423],{"class":541},[531,1856,972],{"class":537},[531,1858,1859,1862,1865,1867,1870,1873,1876,1879],{"class":533,"line":577},[531,1860,1861],{"class":548},"    return",[531,1863,1864],{"class":537}," slog.",[531,1866,635],{"class":541},[531,1868,1869],{"class":537},"(slog.",[531,1871,1872],{"class":541},"NewTextHandler",[531,1874,1875],{"class":537},"(io.Discard, ",[531,1877,1878],{"class":583},"nil",[531,1880,1881],{"class":537},"))\n",[531,1883,1884],{"class":533,"line":589},[531,1885,798],{"class":537},[13,1887,1888,1891,1892,484],{},[128,1889,1890],{},"io.Discard"," 就是\"扔掉\"。不是\"写到 stdout 你看不到\"，是",[23,1893,1894],{},"连 stdout 都没有",[13,1896,1897],{},"所以：",[466,1899,1900,1903,1906],{},[469,1901,1902],{},"你写的业务日志？如果没显式配 logger，全丢。",[469,1904,1905],{},"框架自己的错误日志？全丢。",[469,1907,1908,1909,1912],{},"前端日志？如果注册 log 服务时用了 ",[128,1910,1911],{},"log.New()","（它走默认 logger），也全丢。",[13,1914,1915],{},[23,1916,1917],{},"最阴的是：日志接线看起来是接了的，代码也编译过了，但生产包里的日志是空的。",[13,1919,1920],{},"我的教训是：日志这件事，护栏不能只检查\"有没有注册\"，得检查\"注册时有没有显式传 logger\"。",[151,1922,1924],{"id":1923},"坑-2关窗拦截用错了-api-就静默失效","坑 2：关窗拦截，用错了 API 就静默失效",[13,1926,1927],{},"需求：点关闭按钮 → 隐藏到托盘，不退出。",[13,1929,1930],{},"我第一版这么写的：",[395,1932,1934],{"className":525,"code":1933,"language":527,"meta":403,"style":403},"window.OnWindowEvent(events.Common.WindowClosing, func(e *application.WindowEvent) {\n    window.Hide()\n    e.Cancel()   \u002F\u002F 想拦下\"销毁窗口\"\n})\n",[128,1935,1936,1967,1977,1991],{"__ignoreMap":403},[531,1937,1938,1941,1944,1947,1949,1951,1954,1957,1959,1961,1964],{"class":533,"line":534},[531,1939,1940],{"class":537},"window.",[531,1942,1943],{"class":541},"OnWindowEvent",[531,1945,1946],{"class":537},"(events.Common.WindowClosing, ",[531,1948,1825],{"class":548},[531,1950,545],{"class":537},[531,1952,1953],{"class":1833},"e",[531,1955,1956],{"class":548}," *",[531,1958,640],{"class":541},[531,1960,555],{"class":537},[531,1962,1963],{"class":541},"WindowEvent",[531,1965,1966],{"class":537},") {\n",[531,1968,1969,1972,1975],{"class":533,"line":564},[531,1970,1971],{"class":537},"    window.",[531,1973,1974],{"class":541},"Hide",[531,1976,706],{"class":537},[531,1978,1979,1982,1985,1988],{"class":533,"line":577},[531,1980,1981],{"class":537},"    e.",[531,1983,1984],{"class":541},"Cancel",[531,1986,1987],{"class":537},"()   ",[531,1989,1990],{"class":620},"\u002F\u002F 想拦下\"销毁窗口\"\n",[531,1992,1993],{"class":533,"line":589},[531,1994,604],{"class":537},[13,1996,1997],{},[23,1998,1999],{},"窗口照常销毁。",[13,2001,2002],{},"原因：v3 里有 hook 和 listener 两套机制。",[61,2004,2005,2020],{},[64,2006,2007],{},[67,2008,2009,2011,2016],{},[70,2010],{},[70,2012,2013],{},[128,2014,2015],{},"RegisterHook",[70,2017,2018],{},[128,2019,1943],{},[77,2021,2022,2033,2047],{},[67,2023,2024,2027,2030],{},[82,2025,2026],{},"怎么跑",[82,2028,2029],{},"同步，按顺序",[82,2031,2032],{},"每个开个 goroutine 各跑各的",[67,2034,2035,2041,2044],{},[82,2036,2037,2040],{},[128,2038,2039],{},"Cancel()"," 有用吗",[82,2042,2043],{},"有用，立刻拦下",[82,2045,2046],{},"没用，抢不过框架自己的 listener",[67,2048,2049,2052,2055],{},[82,2050,2051],{},"适合干什么",[82,2053,2054],{},"拦截、改行为",[82,2056,2057],{},"旁观、听消息",[13,2059,2060],{},"框架自己在关窗事件上挂了个\"销毁窗口\"的 listener，你用 listener 永远抢不过它。",[13,2062,2063],{},"正确写法：",[395,2065,2067],{"className":525,"code":2066,"language":527,"meta":403,"style":403},"window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {\n    if trayRunning() {\n        window.Hide()\n        e.Cancel()\n        return\n    }\n    e.Cancel()\n    wailsApp.Quit()   \u002F\u002F 托盘没起来，就真退出\n})\n",[128,2068,2069,2093,2104,2113,2122,2127,2132,2140,2153],{"__ignoreMap":403},[531,2070,2071,2073,2075,2077,2079,2081,2083,2085,2087,2089,2091],{"class":533,"line":534},[531,2072,1940],{"class":537},[531,2074,2015],{"class":541},[531,2076,1946],{"class":537},[531,2078,1825],{"class":548},[531,2080,545],{"class":537},[531,2082,1953],{"class":1833},[531,2084,1956],{"class":548},[531,2086,640],{"class":541},[531,2088,555],{"class":537},[531,2090,1963],{"class":541},[531,2092,1966],{"class":537},[531,2094,2095,2098,2101],{"class":533,"line":564},[531,2096,2097],{"class":548},"    if",[531,2099,2100],{"class":541}," trayRunning",[531,2102,2103],{"class":537},"() {\n",[531,2105,2106,2109,2111],{"class":533,"line":577},[531,2107,2108],{"class":537},"        window.",[531,2110,1974],{"class":541},[531,2112,706],{"class":537},[531,2114,2115,2118,2120],{"class":533,"line":589},[531,2116,2117],{"class":537},"        e.",[531,2119,1984],{"class":541},[531,2121,706],{"class":537},[531,2123,2124],{"class":533,"line":601},[531,2125,2126],{"class":548},"        return\n",[531,2128,2129],{"class":533,"line":687},[531,2130,2131],{"class":537},"    }\n",[531,2133,2134,2136,2138],{"class":533,"line":692},[531,2135,1981],{"class":537},[531,2137,1984],{"class":541},[531,2139,706],{"class":537},[531,2141,2142,2145,2148,2150],{"class":533,"line":698},[531,2143,2144],{"class":537},"    wailsApp.",[531,2146,2147],{"class":541},"Quit",[531,2149,1987],{"class":537},[531,2151,2152],{"class":620},"\u002F\u002F 托盘没起来，就真退出\n",[531,2154,2156],{"class":533,"line":2155},9,[531,2157,604],{"class":537},[13,2159,2160,2161,2164,2165],{},"注意那个 ",[128,2162,2163],{},"trayRunning()","——",[23,2166,2167,2168,484],{},"必须是运行期状态，不能写 ",[128,2169,2170],{},"runtime.GOOS == \"darwin\"",[13,2172,2173,2174,2177,2178],{},"因为托盘是在服务启动时创建的，可能失败；而关窗拦截是在 ",[128,2175,2176],{},"main()"," 里注册的，比服务启动还早。两者差一个阶段，",[23,2179,2180],{},"只有跑起来才知道托盘到底起没起来。",[151,2182,2184],{"id":2183},"坑-3前端日志的通道被整个拿掉了","坑 3：前端日志的通道被整个拿掉了",[13,2186,2187,2188,2191],{},"v2 时代，前端调 ",[128,2189,2190],{},"runtime.LogInfo()","，消息自动进后端日志文件。免费的。",[13,2193,2194,2197],{},[23,2195,2196],{},"v3 把这条通道删了。"," 前端运行时里根本没有日志函数了。",[13,2199,2200,2201,172],{},"替代方案是官方 log 服务，",[23,2202,2203],{},"必须手动注册",[395,2205,2207],{"className":525,"code":2206,"language":527,"meta":403,"style":403},"Services: []application.Service{\n    application.NewService(logservice.NewWithConfig(&logservice.Config{\n        Logger:   frontendLogger,   \u002F\u002F 必须显式传！\n        LogLevel: logLevel,\n    })),\n}\n",[128,2208,2209,2221,2247,2255,2260,2265],{"__ignoreMap":403},[531,2210,2211,2213,2215,2217,2219],{"class":533,"line":534},[531,2212,773],{"class":537},[531,2214,640],{"class":541},[531,2216,555],{"class":537},[531,2218,780],{"class":541},[531,2220,561],{"class":537},[531,2222,2223,2225,2227,2230,2233,2235,2237,2240,2242,2245],{"class":533,"line":564},[531,2224,787],{"class":537},[531,2226,790],{"class":541},[531,2228,2229],{"class":537},"(logservice.",[531,2231,2232],{"class":541},"NewWithConfig",[531,2234,545],{"class":537},[531,2236,549],{"class":548},[531,2238,2239],{"class":541},"logservice",[531,2241,555],{"class":537},[531,2243,2244],{"class":541},"Config",[531,2246,561],{"class":537},[531,2248,2249,2252],{"class":533,"line":577},[531,2250,2251],{"class":537},"        Logger:   frontendLogger,   ",[531,2253,2254],{"class":620},"\u002F\u002F 必须显式传！\n",[531,2256,2257],{"class":533,"line":589},[531,2258,2259],{"class":537},"        LogLevel: logLevel,\n",[531,2261,2262],{"class":533,"line":601},[531,2263,2264],{"class":537},"    })),\n",[531,2266,2267],{"class":533,"line":687},[531,2268,798],{"class":537},[13,2270,2271,2272,2275,2276,2279],{},"不注册的后果：前端所有日志只剩 ",[128,2273,2274],{},"console","。而打包后的应用",[23,2277,2278],{},"没有开发者工具","——等于没记。",[13,2281,2282],{},"用户说\"点了没反应\"，你打开日志文件，干干净净。",[151,2284,2286],{"id":2285},"坑-4macos-托盘传空图标会直接-panic","坑 4：macOS 托盘传空图标会直接 panic",[13,2288,2289],{},"不是返回 error，是 panic。",[395,2291,2293],{"className":525,"code":2292,"language":527,"meta":403,"style":403},"\u002F\u002F 必须有兜底\nif len(icon) == 0 {\n    icon = placeholderIcon()\n}\n",[128,2294,2295,2300,2319,2332],{"__ignoreMap":403},[531,2296,2297],{"class":533,"line":534},[531,2298,2299],{"class":620},"\u002F\u002F 必须有兜底\n",[531,2301,2302,2305,2308,2311,2314,2317],{"class":533,"line":564},[531,2303,2304],{"class":548},"if",[531,2306,2307],{"class":541}," len",[531,2309,2310],{"class":537},"(icon) ",[531,2312,2313],{"class":548},"==",[531,2315,2316],{"class":583}," 0",[531,2318,972],{"class":537},[531,2320,2321,2324,2327,2330],{"class":533,"line":577},[531,2322,2323],{"class":537},"    icon ",[531,2325,2326],{"class":548},"=",[531,2328,2329],{"class":541}," placeholderIcon",[531,2331,706],{"class":537},[531,2333,2334],{"class":533,"line":589},[531,2335,798],{"class":537},[13,2337,2338,2339,2342],{},"这个坑有个好处：",[23,2340,2341],{},"它崩得很响","，启动就崩，你立刻能发现。所以我没给它加护栏——护栏的预算要留给\"崩不了但悄悄坏掉\"的问题。",[151,2344,2346],{"id":2345},"坑-5对话框没有同步返回值了","坑 5：对话框没有同步返回值了",[13,2348,2349],{},"v2 的确认框是同步的：",[395,2351,2353],{"className":525,"code":2352,"language":527,"meta":403,"style":403},"if dialog.Confirm(\"要删除吗？\") { ... }\n",[128,2354,2355],{"__ignoreMap":403},[531,2356,2357,2359,2362,2365,2367,2370,2373,2375],{"class":533,"line":534},[531,2358,2304],{"class":548},[531,2360,2361],{"class":537}," dialog.",[531,2363,2364],{"class":541},"Confirm",[531,2366,545],{"class":537},[531,2368,2369],{"class":570},"\"要删除吗？\"",[531,2371,2372],{"class":537},") { ",[531,2374,651],{"class":548},[531,2376,2377],{"class":537}," }\n",[13,2379,2380],{},"v3 没有同步返回，是回调式的。想保住原来的函数签名，只能自己用 channel 桥接：",[395,2382,2384],{"className":525,"code":2383,"language":527,"meta":403,"style":403},"func Confirm(msg string) bool {\n    done := make(chan bool, 1)\n    d := app.Dialog.Question()\n    d.SetTitle(msg)\n    d.AddButton(\"取消\").OnClick(func() { done \u003C- false })\n    d.AddButton(\"确定\").OnClick(func() { done \u003C- true })\n    d.Show()\n    return \u003C-done\n}\n",[128,2385,2386,2408,2433,2448,2459,2492,2520,2528,2538],{"__ignoreMap":403},[531,2387,2388,2390,2393,2395,2398,2401,2403,2406],{"class":533,"line":534},[531,2389,1825],{"class":548},[531,2391,2392],{"class":541}," Confirm",[531,2394,545],{"class":537},[531,2396,2397],{"class":1833},"msg",[531,2399,2400],{"class":548}," string",[531,2402,1845],{"class":537},[531,2404,2405],{"class":548},"bool",[531,2407,972],{"class":537},[531,2409,2410,2413,2415,2418,2420,2423,2426,2429,2431],{"class":533,"line":564},[531,2411,2412],{"class":537},"    done ",[531,2414,629],{"class":548},[531,2416,2417],{"class":541}," make",[531,2419,545],{"class":537},[531,2421,2422],{"class":548},"chan",[531,2424,2425],{"class":548}," bool",[531,2427,2428],{"class":537},", ",[531,2430,1689],{"class":583},[531,2432,684],{"class":537},[531,2434,2435,2438,2440,2443,2446],{"class":533,"line":577},[531,2436,2437],{"class":537},"    d ",[531,2439,629],{"class":548},[531,2441,2442],{"class":537}," app.Dialog.",[531,2444,2445],{"class":541},"Question",[531,2447,706],{"class":537},[531,2449,2450,2453,2456],{"class":533,"line":589},[531,2451,2452],{"class":537},"    d.",[531,2454,2455],{"class":541},"SetTitle",[531,2457,2458],{"class":537},"(msg)\n",[531,2460,2461,2463,2466,2468,2471,2473,2476,2478,2480,2483,2486,2489],{"class":533,"line":601},[531,2462,2452],{"class":537},[531,2464,2465],{"class":541},"AddButton",[531,2467,545],{"class":537},[531,2469,2470],{"class":570},"\"取消\"",[531,2472,912],{"class":537},[531,2474,2475],{"class":541},"OnClick",[531,2477,545],{"class":537},[531,2479,1825],{"class":548},[531,2481,2482],{"class":537},"() { done ",[531,2484,2485],{"class":548},"\u003C-",[531,2487,2488],{"class":583}," false",[531,2490,2491],{"class":537}," })\n",[531,2493,2494,2496,2498,2500,2503,2505,2507,2509,2511,2513,2515,2518],{"class":533,"line":687},[531,2495,2452],{"class":537},[531,2497,2465],{"class":541},[531,2499,545],{"class":537},[531,2501,2502],{"class":570},"\"确定\"",[531,2504,912],{"class":537},[531,2506,2475],{"class":541},[531,2508,545],{"class":537},[531,2510,1825],{"class":548},[531,2512,2482],{"class":537},[531,2514,2485],{"class":548},[531,2516,2517],{"class":583}," true",[531,2519,2491],{"class":537},[531,2521,2522,2524,2526],{"class":533,"line":692},[531,2523,2452],{"class":537},[531,2525,915],{"class":541},[531,2527,706],{"class":537},[531,2529,2530,2532,2535],{"class":533,"line":698},[531,2531,1861],{"class":548},[531,2533,2534],{"class":548}," \u003C-",[531,2536,2537],{"class":537},"done\n",[531,2539,2540],{"class":533,"line":2155},[531,2541,798],{"class":537},[13,2543,2544],{},[23,2545,2546],{},"这类\"编译过了但语义变了\"的改动，只能靠实跑发现。",[151,2548,2550],{"id":2549},"坑-6绑定调用是数字-id-寻址","坑 6：绑定调用是数字 ID 寻址",[13,2552,2553,2554,2557],{},"v3 生成的绑定，前端调方法是按 ",[23,2555,2556],{},"hash ID"," 调，不是按方法名：",[395,2559,2563],{"className":2560,"code":2561,"language":2562,"meta":403,"style":403},"language-ts shiki shiki-themes github-dark github-light","Call.ByID(1234567890)\n","ts",[128,2564,2565],{"__ignoreMap":403},[531,2566,2567,2570,2573,2575,2578],{"class":533,"line":534},[531,2568,2569],{"class":537},"Call.",[531,2571,2572],{"class":541},"ByID",[531,2574,545],{"class":537},[531,2576,2577],{"class":583},"1234567890",[531,2579,684],{"class":537},[13,2581,2582,2583,2586],{},"方法改名、改包路径，ID 就变了。如果前端没重新生成绑定，",[23,2584,2585],{},"编译期完全看不出来","，运行时才报错。",[13,2588,2589,2590,2593],{},"好消息是这个 hash 是",[23,2591,2592],{},"可复算的","（FNV-1a）。所以我做了件事：护栏里自己复算一遍 ID，和生成产物逐个对账——当时全部命中。",[151,2595,2597,2598,2600,2601],{"id":2596},"坑-7wails3-build-不转发-ldflags","坑 7：",[128,2599,1068],{}," 不转发 ",[128,2602,1083],{},[395,2604,2606],{"className":1317,"code":2605,"language":1319,"meta":403,"style":403},"wails3 build -ldflags \"-X main.Version=1.2.3\"   # 不生效\n",[128,2607,2608],{"__ignoreMap":403},[531,2609,2610,2612,2615,2618,2621],{"class":533,"line":534},[531,2611,1336],{"class":541},[531,2613,2614],{"class":570}," build",[531,2616,2617],{"class":583}," -ldflags",[531,2619,2620],{"class":570}," \"-X main.Version=1.2.3\"",[531,2622,2623],{"class":620},"   # 不生效\n",[13,2625,2626,2627,814,2630,814,2633,2636,2637,2639],{},"实测只转发 ",[128,2628,2629],{},"EXTRA_TAGS",[128,2631,2632],{},"GOOS",[128,2634,2635],{},"ARCH"," 这几个，",[128,2638,1083],{}," 被吃掉。",[13,2641,2642],{},"版本注入只能走 Taskfile 变量：",[395,2644,2647],{"className":2645,"code":2646,"language":400},[398],"scripts\u002Fbuild.sh → wails3 build VERSION=x → Taskfile 的 VERSION_LDFLAGS → 各平台 -ldflags\n",[128,2648,2646],{"__ignoreMap":403},[13,2650,2651,2654,2655,2658],{},[23,2652,2653],{},"漏了这条链的症状："," 安装包里版本对，应用里版本恒为 ",[128,2656,2657],{},"dev","。因为是两个来源，一个对了一个没对。",[151,2660,2662,2663,2666],{"id":2661},"坑-8拷贝构建脚本时丢了-output-变量","坑 8：拷贝构建脚本时丢了 ",[128,2664,2665],{},"OUTPUT"," 变量",[13,2668,2669,2670,2673,2674,2676],{},"v3 迁移时照着官方模板抄平台 Taskfile，把输出路径写死成 ",[128,2671,2672],{},"BIN_DIR\u002FAPP_NAME","，顺手把 ",[128,2675,2665],{}," 变量丢了。",[13,2678,2679],{},"后果分两种：",[466,2681,2682,2689],{},[469,2683,2684,2685,2688],{},"macOS 的 universal 构建：两次编译（amd64 \u002F arm64）输出到同一个文件，互相覆盖，",[128,2686,2687],{},"lipo"," 合并时报错。",[469,2690,2691,2692,2695,2696,2698],{},"Linux \u002F Windows：",[23,2693,2694],{},"完全静默","——传了 ",[128,2697,2665],{},"，但产物落在别处，没人发现。",[151,2700,2702],{"id":2701},"坑-9dev-模式页面一片空白","坑 9：dev 模式页面一片空白",[13,2704,2705,2708],{},[128,2706,2707],{},"wails3 dev"," 起来，窗口是白的，控制台没报错。",[13,2710,2711,2712,2715],{},"原因很无聊：前端 dev server 默认绑在 IPv6 的 ",[128,2713,2714],{},"localhost","，而 wails3 的代理强制走 IPv4。两边对不上，页面加载失败。",[13,2717,2718,2721,2722,2725],{},[23,2719,2720],{},"这类问题的排查成本极高","，因为\"白屏\"什么信息都不给你。最后是靠 ",[128,2723,2724],{},"curl"," 分别试 IPv4 和 IPv6 才定位到。",[151,2727,2729],{"id":2728},"坑-10版本号载体换地方了","坑 10：版本号载体换地方了",[13,2731,521,2732,2734,2735,2737,2738,725,2740,172],{},[128,2733,1241],{},"。\nv3：",[128,2736,1246],{},"，而且 ",[128,2739,1642],{},[23,2741,2742],{},"必须显式渲染",[395,2744,2746],{"className":1317,"code":2745,"language":1319,"meta":403,"style":403},"wails3 task common:update:build-assets\n",[128,2747,2748],{"__ignoreMap":403},[531,2749,2750,2752,2755],{"class":533,"line":534},[531,2751,1336],{"class":541},[531,2753,2754],{"class":570}," task",[531,2756,2757],{"class":570}," common:update:build-assets\n",[13,2759,2760],{},"漏了这一步，就会出现\"应用内新版本、系统里旧版本\"的双份真相。",[144,2762],{},[43,2764,2766],{"id":2765},"六怎么用好-v3","六、怎么用好 v3",[13,2768,2769],{},"前面讲的是\"升级怎么不踩坑\"，这里是\"日常怎么写才顺\"。",[151,2771,2773,2774,2776],{"id":2772},"_1-main-不是启动应用是装配应用","1. ",[128,2775,2176],{}," 不是启动应用，是装配应用",[13,2778,2779,2780,2782,2783],{},"v3 把能力对象化之后，",[128,2781,2176],{}," 的职责变了：",[23,2784,2785],{},"按顺序把零件装好，然后交给框架跑。",[13,2787,2788],{},"我把它分成三段：",[395,2790,2792],{"className":525,"code":2791,"language":527,"meta":403,"style":403},"\u002F\u002F 第一段：建应用（服务、资源、日志、错误处理、单实例）\n\u002F\u002F 第二段：建窗口（主窗、宠物窗、强提醒窗）\n\u002F\u002F 第三段：注入依赖 + Run\n",[128,2793,2794,2799,2804],{"__ignoreMap":403},[531,2795,2796],{"class":533,"line":534},[531,2797,2798],{"class":620},"\u002F\u002F 第一段：建应用（服务、资源、日志、错误处理、单实例）\n",[531,2800,2801],{"class":533,"line":564},[531,2802,2803],{"class":620},"\u002F\u002F 第二段：建窗口（主窗、宠物窗、强提醒窗）\n",[531,2805,2806],{"class":533,"line":577},[531,2807,2808],{"class":620},"\u002F\u002F 第三段：注入依赖 + Run\n",[13,2810,2811],{},"关键是第三段的第一句：",[395,2813,2815],{"className":525,"code":2814,"language":527,"meta":403,"style":403},"appSvc.attach(wailsApp)   \u002F\u002F 必须在 Run 之前\nwailsApp.Run()\n",[128,2816,2817,2831],{"__ignoreMap":403},[531,2818,2819,2822,2825,2828],{"class":533,"line":534},[531,2820,2821],{"class":537},"appSvc.",[531,2823,2824],{"class":541},"attach",[531,2826,2827],{"class":537},"(wailsApp)   ",[531,2829,2830],{"class":620},"\u002F\u002F 必须在 Run 之前\n",[531,2832,2833,2835,2837],{"class":533,"line":564},[531,2834,701],{"class":537},[531,2836,542],{"class":541},[531,2838,706],{"class":537},[13,2840,2841,2842,2844,2845,2847],{},"因为 ",[128,2843,721],{}," 一进去就回调服务的启动方法，那时候 ",[128,2846,1478],{}," 得已经就位。",[151,2849,2851],{"id":2850},"_2-窗口是并列的别做子窗口","2. 窗口是并列的，别做子窗口",[13,2853,2854],{},"一旦你要做\"浮在别的应用上面的东西\"，就别想着\"在主窗里开个子窗口\"。",[13,2856,2857],{},"Morrow 的四个窗口全是平级的：",[61,2859,2860,2872],{},[64,2861,2862],{},[67,2863,2864,2866,2869],{},[70,2865,100],{},[70,2867,2868],{},"关键设置",[70,2870,2871],{},"为什么必须独立",[77,2873,2874,2885,2896,2907],{},[67,2875,2876,2879,2882],{},[82,2877,2878],{},"主窗",[82,2880,2881],{},"正常窗口，macOS 保留交通灯",[82,2883,2884],{},"—",[67,2886,2887,2890,2893],{},[82,2888,2889],{},"宠物窗",[82,2891,2892],{},"透明 + 无边框 + 置顶",[82,2894,2895],{},"要盖住别的应用，子窗口做不到",[67,2897,2898,2901,2904],{},[82,2899,2900],{},"强提醒",[82,2902,2903],{},"全屏透明遮罩，启动就隐藏",[82,2905,2906],{},"要铺满屏幕压住一切",[67,2908,2909,2912,2915],{},[82,2910,2911],{},"副屏强提醒",[82,2913,2914],{},"每块屏一个",[82,2916,2917],{},"同上",[13,2919,2920,2921],{},"多窗口带来的问题是：",[23,2922,2923],{},"同一个前端包，要有不同的界面骨架。",[13,2925,2926],{},"解法是用 hash 路由区分：",[395,2928,2930],{"className":525,"code":2929,"language":527,"meta":403,"style":403},"URL: \"\u002F#\u002Fpet\"   \u002F\u002F 宠物窗口\n",[128,2931,2932],{"__ignoreMap":403},[531,2933,2934,2937,2940],{"class":533,"line":534},[531,2935,2936],{"class":537},"URL: ",[531,2938,2939],{"class":570},"\"\u002F#\u002Fpet\"",[531,2941,2942],{"class":620},"   \u002F\u002F 宠物窗口\n",[13,2944,2945,2946,2949],{},"这条路由在 ",[128,2947,2948],{},"App.vue"," 里标记为\"裸页面\"，跳过侧栏和标题栏。一份代码，三种壳。",[13,2951,2952],{},[23,2953,2954],{},"两个建窗的坑，都属于\"看着能跑\"：",[466,2956,2957,2977],{},[469,2958,2959,2962,2963,2966,2967,2970,2971,2973,2974,484],{},[23,2960,2961],{},"坐标有两套。"," 建窗参数里的 ",[128,2964,2965],{},"X\u002FY"," 是\"相对某块屏幕\"的，",[128,2968,2969],{},"SetPosition()"," 用的是\"全局坐标\"。混用之后，多屏用户每次启动宠物都被拽回主屏。我干脆不用建窗的 ",[128,2972,2965],{},"，统一在创建后 ",[128,2975,2976],{},"SetPosition",[469,2978,2979,2985,2986,2989],{},[23,2980,2981,2984],{},[128,2982,2983],{},"Hidden: true"," 要写在建窗参数里","，不是创建完再 ",[128,2987,2988],{},"Hide()","。后者会先闪一下。",[151,2991,2993],{"id":2992},"_3-透明窗口三个设置-一个编译参数","3. 透明窗口：三个设置 + 一个编译参数",[13,2995,2996],{},"这是 macOS 上最容易做错的地方。",[13,2998,2999,3000,3003],{},"以为设一个 ",[128,3001,3002],{},"BackgroundTypeTransparent"," 就行？不行，你会看到宠物周围糊着一块底色。",[13,3005,3006],{},[23,3007,3008],{},"要四个条件同时成立：",[395,3010,3012],{"className":525,"code":3011,"language":527,"meta":403,"style":403},"application.WebviewWindowOptions{\n    Frameless:        true,\n    BackgroundType:   application.BackgroundTypeTransparent,\n    BackgroundColour: application.RGBA{},   \u002F\u002F 零值\n    Mac: application.MacWindow{\n        DisableShadow: true,\n        Backdrop:      application.MacBackdropTransparent,\n    },\n}\n",[128,3013,3014,3025,3035,3040,3058,3072,3081,3086,3091],{"__ignoreMap":403},[531,3015,3016,3018,3020,3023],{"class":533,"line":534},[531,3017,640],{"class":541},[531,3019,555],{"class":537},[531,3021,3022],{"class":541},"WebviewWindowOptions",[531,3024,561],{"class":537},[531,3026,3027,3030,3033],{"class":533,"line":564},[531,3028,3029],{"class":537},"    Frameless:        ",[531,3031,3032],{"class":583},"true",[531,3034,574],{"class":537},[531,3036,3037],{"class":533,"line":577},[531,3038,3039],{"class":537},"    BackgroundType:   application.BackgroundTypeTransparent,\n",[531,3041,3042,3045,3047,3049,3052,3055],{"class":533,"line":589},[531,3043,3044],{"class":537},"    BackgroundColour: ",[531,3046,640],{"class":541},[531,3048,555],{"class":537},[531,3050,3051],{"class":541},"RGBA",[531,3053,3054],{"class":537},"{},   ",[531,3056,3057],{"class":620},"\u002F\u002F 零值\n",[531,3059,3060,3063,3065,3067,3070],{"class":533,"line":601},[531,3061,3062],{"class":537},"    Mac: ",[531,3064,640],{"class":541},[531,3066,555],{"class":537},[531,3068,3069],{"class":541},"MacWindow",[531,3071,561],{"class":537},[531,3073,3074,3077,3079],{"class":533,"line":687},[531,3075,3076],{"class":537},"        DisableShadow: ",[531,3078,3032],{"class":583},[531,3080,574],{"class":537},[531,3082,3083],{"class":533,"line":692},[531,3084,3085],{"class":537},"        Backdrop:      application.MacBackdropTransparent,\n",[531,3087,3088],{"class":533,"line":698},[531,3089,3090],{"class":537},"    },\n",[531,3092,3093],{"class":533,"line":2155},[531,3094,798],{"class":537},[13,3096,3097],{},"第四个在代码外面：",[395,3099,3101],{"className":1317,"code":3100,"language":1319,"meta":403,"style":403},"# 只在 macOS 构建时加\nEXTRA_TAGS=\"private_mac_apis\"\n",[128,3102,3103,3108],{"__ignoreMap":403},[531,3104,3105],{"class":533,"line":534},[531,3106,3107],{"class":620},"# 只在 macOS 构建时加\n",[531,3109,3110,3112,3114],{"class":533,"line":564},[531,3111,2629],{"class":537},[531,3113,2326],{"class":548},[531,3115,3116],{"class":570},"\"private_mac_apis\"\n",[13,3118,3119],{},"缺一个的症状：",[61,3121,3122,3132],{},[64,3123,3124],{},[67,3125,3126,3129],{},[70,3127,3128],{},"缺什么",[70,3130,3131],{},"你会看到",[77,3133,3134,3144,3155,3165],{},[67,3135,3136,3141],{},[82,3137,3138],{},[128,3139,3140],{},"BackgroundType",[82,3142,3143],{},"整个窗口是个不透明方块",[67,3145,3146,3152],{},[82,3147,3148,3151],{},[128,3149,3150],{},"BackgroundColour"," 零值",[82,3153,3154],{},"边上有一圈黑边",[67,3156,3157,3162],{},[82,3158,3159],{},[128,3160,3161],{},"Mac.Backdrop",[82,3163,3164],{},"原生窗口透明了，但网页本身还是白的",[67,3166,3167,3172],{},[82,3168,3169],{},[128,3170,3171],{},"private_mac_apis",[82,3173,3174],{},"上面那行配了也不生效，退化成上一条",[13,3176,3177,3178],{},"最后一条最坑：",[23,3179,3180,3181,3183],{},"它是编译参数，代码里看不出来，编辑器不报错，",[128,3182,493],{}," 也过。",[13,3185,3186,3187],{},"所以有个通用经验：",[23,3188,3189],{},"遇到\"配了但不生效\"的选项，先怀疑编译参数。",[151,3191,3193],{"id":3192},"_4-约定好数据长什么样两边照着来","4. 约定好数据长什么样，两边照着来",[13,3195,3196],{},"这是我整个项目里最想推荐的做法。",[13,3198,3199,3201],{},[128,3200,138],{}," 下有三个包，什么业务都不做，只定义\"数据长什么样\"：",[61,3203,3204,3217],{},[64,3205,3206],{},[67,3207,3208,3211,3214],{},[70,3209,3210],{},"包",[70,3212,3213],{},"定义什么",[70,3215,3216],{},"前端对应",[77,3218,3219,3237,3255],{},[67,3220,3221,3226,3232],{},[82,3222,3223],{},[128,3224,3225],{},"apperr",[82,3227,3228,3229],{},"错误长这样：",[128,3230,3231],{},"{code, message, detail}",[82,3233,3234],{},[128,3235,3236],{},"lib\u002Finvoke.ts",[67,3238,3239,3244,3251],{},[82,3240,3241],{},[128,3242,3243],{},"event",[82,3245,3246,3247,3250],{},"事件名格式 ",[128,3248,3249],{},"\u003C模块>:\u003C动作>"," + 载荷",[82,3252,3253],{},[128,3254,1529],{},[67,3256,3257,3262,3265],{},[82,3258,3259],{},[128,3260,3261],{},"page",[82,3263,3264],{},"分页请求和返回",[82,3266,3267],{},[128,3268,3269],{},"lib\u002Fpage.ts",[13,3271,3272],{},[23,3273,3274],{},"它的价值在框架换代那天才显现。",[13,3276,3277,3278,3281,3282,484],{},"举个例子。v2 的错误处理有个著名的坑：Go 返回对象，前端拿到的是 ",[128,3279,3280],{},"\"[object Object]\"","，结构化信息全丢。所以 v2 时代 Go 侧",[23,3283,3284],{},"必须返回字符串",[13,3286,3287,3288,3290],{},"v3 改成了 ",[128,3289,1197],{},"，框架自己序列化。",[13,3292,3293],{},[23,3294,3295,3296,3299],{},"结果：Go 侧改了一处，前端 ",[128,3297,3298],{},"normalizeError"," 一行没动。",[13,3301,3302,3303,3306,3307],{},"因为前端拿到的",[23,3304,3305],{},"还是\"一个 message 里装着 JSON 的 Error\"","，只是这个 JSON 现在由框架保证格式。",[23,3308,3309],{},"约定没变，实现变了。",[13,3311,3312],{},"这就是把\"约定\"单独抽出来的好处：换框架时，改的是接线口，不是所有使用方。",[13,3314,3315,3316,3319],{},"代价是前端要维护一份镜像常量。我的做法是用护栏双向校验——改了 Go 不同步前端，",[128,3317,3318],{},"make test"," 就红。",[13,3321,3322,3325],{},[23,3323,3324],{},"一条纪律：镜像只许复制\"常量和类型\"，不许复制\"逻辑\"。"," 同一条规则两个实现，比一个常量两份更糟。",[151,3327,3329],{"id":3328},"_5-把不报错的问题变成会红的检查","5. 把\"不报错的问题\"变成会红的检查",[13,3331,3332,3333],{},"前面十个坑，一半以上属于同一类：",[23,3334,3335],{},"坏了不吭声。",[13,3337,3338,3339],{},"这类问题没法靠 code review，也没法靠文档。唯一可靠的办法是：",[23,3340,3341],{},"写成自动检查，让它变红。",[13,3343,3344,3345,3348,3349,3352,3353,3355],{},"我在 ",[128,3346,3347],{},"internal\u002Fguard\u002F"," 里放了 20 多个检查，全是 ",[128,3350,3351],{},"go test"," 形式，跑在 ",[128,3354,3318],{}," 里。它们用 Go 的 AST 解析自己的源码，检查\"该接的线接了没有\"：",[61,3357,3358,3368],{},[64,3359,3360],{},[67,3361,3362,3365],{},[70,3363,3364],{},"检查什么",[70,3366,3367],{},"不检查会怎样",[77,3369,3370,3386,3394,3406,3417,3425],{},[67,3371,3372,3383],{},[82,3373,3374,814,3377,814,3380,3382],{},[128,3375,3376],{},"Options.Logger",[128,3378,3379],{},"Assets",[128,3381,1435],{}," 都接了",[82,3384,3385],{},"框架错误无人可见",[67,3387,3388,3391],{},[82,3389,3390],{},"log 服务显式注册了，且传了 logger",[82,3392,3393],{},"前端日志全丢",[67,3395,3396,3401],{},[82,3397,3398,3400],{},[128,3399,1083],{}," 注入的变量真实存在",[82,3402,3403,3404],{},"应用内版本恒为 ",[128,3405,2657],{},[67,3407,3408,3414],{},[82,3409,3410,3413],{},[128,3411,3412],{},"mcp"," 标签没进默认构建",[82,3415,3416],{},"生产包多开一个本机端口",[67,3418,3419,3422],{},[82,3420,3421],{},"关窗逻辑是\"托盘在→隐藏、托盘不在→退出\"",[82,3423,3424],{},"关窗后回不来 \u002F 留下僵尸进程",[67,3426,3427,3430],{},[82,3428,3429],{},"macOS 应用菜单挂上了",[82,3431,3432],{},"输入框复制粘贴失效",[13,3434,3435],{},[23,3436,3437],{},"但这里有一条铁律，比检查本身重要：",[10,3439,3440],{},[13,3441,3442],{},[23,3443,3444],{},"检查本身要能发现\"自己什么都没查到\"。",[13,3446,3447,3448,3451],{},"如果检查是靠正则或 AST 找代码，一旦代码换个写法，它找不到东西了——这时候必须",[23,3449,3450],{},"报错","，不能当作\"通过\"。",[13,3453,3454],{},"否则检查会从\"拦问题\"退化成\"永远绿的摆设\"，还给你一种\"很安全\"的错觉。",[13,3456,3457],{},"我踩过两个反例：",[1410,3459,3460,3472],{},[469,3461,3462,3465,3466,3468,3469,3471],{},[23,3463,3464],{},"解析范围必须等于书写范围。"," 我的绑定检查只解析 ",[128,3467,134],{},"。于是\"所有绑定方法必须写在 ",[128,3470,134],{},"\"变成了项目铁律——不是框架要求，是护栏只认这个文件。写到别处能编译、能生成，但护栏会误报。",[469,3473,3474,3477,3478,3480],{},[23,3475,3476],{},"只检查第一个匹配项 = 脆弱。"," 我的关窗检查只解析 ",[128,3479,130],{}," 里第一个关窗钩子，于是\"钩子注册顺序\"变成了不能改的约定——强提醒窗口的钩子必须排在主窗之后，否则护栏抓错对象。",[13,3482,3483,3486],{},[23,3484,3485],{},"检查不是免费的，每条都在给未来加约束。"," 但比起\"用户投诉时你打开日志发现什么都没有\"，这笔买卖划算。",[144,3488],{},[43,3490,3492],{"id":3491},"七值不值得升","七、值不值得升",[151,3494,3495],{"id":3495},"该升的情况",[466,3497,3498,3505,3512,3519],{},[469,3499,3500,3501,3504],{},"你在用 v2，且",[23,3502,3503],{},"macOS 需要托盘","——光这一条就够",[469,3506,3507,3508,3511],{},"你想要 ",[23,3509,3510],{},"AI 能直接操作你的应用","（MCP）——只有 v3 有",[469,3513,3514,3515,3518],{},"你需要",[23,3516,3517],{},"多窗口","（悬浮窗、覆盖层、多屏）",[469,3520,3521],{},"你不介意花两三天做迁移和验证",[151,3523,3524],{"id":3524},"别升的情况",[466,3526,3527,3530,3533],{},[469,3528,3529],{},"你的应用就是个单窗口工具，v2 跑得好好的",[469,3531,3532],{},"你受不了 beta 的 API 变动风险",[469,3534,3535],{},"你没有自动化测试\u002F检查，升级后没法验证\"行为没变\"",[151,3537,3539],{"id":3538},"关于-beta-的实话","关于 beta 的实话",[13,3541,3542,3543,3545],{},"我用的是 ",[128,3544,370],{},"。beta 意味着：",[466,3547,3548,3551,3556],{},[469,3549,3550],{},"API 还会动",[469,3552,3553],{},[23,3554,3555],{},"文档永远落后源码半个版本",[469,3557,3558],{},"遇到问题，GitHub issue 比文档有用",[13,3560,3561],{},"三个月下来最实用的一条经验：",[10,3563,3564],{},[13,3565,3566],{},[23,3567,3568],{},"读源码注释，比读文档有用。",[13,3570,3571,3572,3575,3576,3579],{},"前面那些\"为什么不能这么写\"的结论，大部分不是文档里抄的，是翻 ",[128,3573,3574],{},"webview_window_darwin.m","、看 ",[128,3577,3578],{},"HandleWindowEvent"," 的实现、读上游 issue #4389 看出来的。",[144,3581],{},[43,3583,3584],{"id":3584},"最后三句",[1410,3586,3587,3593,3602],{},[469,3588,3589,3592],{},[23,3590,3591],{},"Wails 是\"Go + 系统浏览器\"，轻、干净，但不是开箱即用。"," 框架该给你的可观测性、构建链、错误处理，得自己补。",[469,3594,3595,3598,3599,484],{},[23,3596,3597],{},"v3 白给的好处是平台能力（托盘、多窗口、对象化 API），要你付的成本是接线责任（日志、事件、版本注入、应用菜单）。"," 好处你会自动享受到，成本不会——",[23,3600,3601],{},"因为它们全都不报错",[469,3603,3604,3607],{},[23,3605,3606],{},"所以真正的经验不是\"记住 10 个坑\"，是养成一个习惯：凡是\"不做也不会报错\"的事，都写成会红的检查。"," 那条检查可能就 20 行，但它替你记住了三个月后一定会忘的事。",[144,3609],{},[13,3611,3612],{},[3613,3614,3615,3616,3619],"em",{},"项目开源在 ",[17,3617,37],{"href":37,"rel":3618},[39]," ，欢迎来看代码（注释比代码多，那是有意的）。",[3621,3622,3623],"style",{},"html pre.shiki code .shWlK, html code.shiki .shWlK{--shiki-default:#E1E4E8;--shiki-light:#24292E}html pre.shiki code .sqoU-, html code.shiki .sqoU-{--shiki-default:#B392F0;--shiki-light:#6F42C1}html pre.shiki code .spKkM, html code.shiki .spKkM{--shiki-default:#F97583;--shiki-light:#D73A49}html pre.shiki code .skb7c, html code.shiki .skb7c{--shiki-default:#9ECBFF;--shiki-light:#032F62}html pre.shiki code .sTU5a, html code.shiki .sTU5a{--shiki-default:#79B8FF;--shiki-light:#005CC5}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html pre.shiki code .si27w, html code.shiki .si27w{--shiki-default:#6A737D;--shiki-light:#6A737D}html pre.shiki code .sdxZB, html code.shiki .sdxZB{--shiki-default:#FFAB70;--shiki-light:#E36209}",{"title":403,"searchDepth":564,"depth":564,"links":3625},[3626,3627,3633,3639,3649,3659,3673,3681,3686],{"id":45,"depth":564,"text":45},{"id":148,"depth":564,"text":149,"children":3628},[3629,3630,3631,3632],{"id":153,"depth":577,"text":153},{"id":164,"depth":577,"text":165},{"id":265,"depth":577,"text":266},{"id":313,"depth":577,"text":314},{"id":356,"depth":564,"text":357,"children":3634},[3635,3636,3637,3638],{"id":363,"depth":577,"text":364},{"id":374,"depth":577,"text":375},{"id":418,"depth":577,"text":418},{"id":461,"depth":577,"text":461},{"id":508,"depth":564,"text":509,"children":3640},[3641,3642,3644,3646,3647,3648],{"id":515,"depth":577,"text":516},{"id":732,"depth":577,"text":3643},"2. 绑定：Bind → Services",{"id":824,"depth":577,"text":3645},"3. runtime 包没了，能力长在对象上",{"id":1011,"depth":577,"text":1012},{"id":1052,"depth":577,"text":1053},{"id":1087,"depth":577,"text":1087},{"id":1290,"depth":564,"text":1291,"children":3650},[3651,3652,3653,3654,3655,3656,3657,3658],{"id":1313,"depth":577,"text":1314},{"id":1363,"depth":577,"text":1364},{"id":1404,"depth":577,"text":1405},{"id":1504,"depth":577,"text":1505},{"id":1565,"depth":577,"text":1566},{"id":1602,"depth":577,"text":1603},{"id":1649,"depth":577,"text":1650},{"id":1656,"depth":577,"text":1656},{"id":1785,"depth":564,"text":1786,"children":3660},[3661,3662,3663,3664,3665,3666,3667,3669,3671,3672],{"id":1799,"depth":577,"text":1800},{"id":1923,"depth":577,"text":1924},{"id":2183,"depth":577,"text":2184},{"id":2285,"depth":577,"text":2286},{"id":2345,"depth":577,"text":2346},{"id":2549,"depth":577,"text":2550},{"id":2596,"depth":577,"text":3668},"坑 7：wails3 build 不转发 -ldflags",{"id":2661,"depth":577,"text":3670},"坑 8：拷贝构建脚本时丢了 OUTPUT 变量",{"id":2701,"depth":577,"text":2702},{"id":2728,"depth":577,"text":2729},{"id":2765,"depth":564,"text":2766,"children":3674},[3675,3677,3678,3679,3680],{"id":2772,"depth":577,"text":3676},"1. main() 不是启动应用，是装配应用",{"id":2850,"depth":577,"text":2851},{"id":2992,"depth":577,"text":2993},{"id":3192,"depth":577,"text":3193},{"id":3328,"depth":577,"text":3329},{"id":3491,"depth":564,"text":3492,"children":3682},[3683,3684,3685],{"id":3495,"depth":577,"text":3495},{"id":3524,"depth":577,"text":3524},{"id":3538,"depth":577,"text":3539},{"id":3584,"depth":564,"text":3584},null,"2026-10-23","md",{},"\u002Fposts\u002Fwails-v3-upgrade","morrow",{"title":5,"description":403},"wails-v3-upgrade","posts\u002Fwails-v3-upgrade","Wails v3 删掉了一堆 v2 时代的妥协：macOS 终于有托盘、日志直接接标准库、能力从全局函数变成对象方法。但它也把一批接线责任还给了你——日志、事件、版本注入、应用菜单，漏接一条程序照常跑，只是某块功能默默没了。这是一次从 v2.15 到 beta.23 的真实迁移：为什么升、怎么升、踩了什么。",[219,186,3698],"升级迁移","IscaW4T1uL4RavtgfofbQreCPKOcQDCx975oU5I3n2g",[3701,3705,3706,3710,3714,3717,3720],{"title":3702,"slug":3703,"date":3704},"不引 Ghostscript：我用纯 Go 给 PDF 做了一场「压缩手术」","pure-go-pdf-compression","2026-10-24",{"title":5,"slug":3694,"date":3688},{"title":3707,"slug":3708,"date":3709},"用 RAG + FAISS 构建古诗词 AI 取名系统：从向量检索到六层质量过滤","chinese-poetry-rag","2026-10-15",{"title":3711,"slug":3712,"date":3713},"一个人做 AI 产品，我踩过的 9 个真实的坑","ecom-detail-ai-part3-pitfalls","2026-10-14",{"title":3715,"slug":3716,"date":3713},"Wails v2 实战：用 Go + Vue3 做一个真正能用的 AI 桌面应用","ecom-detail-ai-part2-wails",{"title":3718,"slug":3719,"date":3713},"我用 Go 搭了一条 AI Agent 流水线：从 1 张商品图到一整套淘宝详情页","ecom-detail-ai-part1-architecture",{"title":3721,"slug":3722,"date":3723},"AI 时代，如何把一个需求真正落地成应用？——一条\"文档驱动 + 实测修订\"的实战路径","ai-requirement-landing","2026-10-13",{"id":3725,"title":33,"architecture":3687,"body":3726,"category":4104,"client":4105,"cover":4106,"date":3688,"demo":3687,"demoMobile":3687,"description":403,"extension":3689,"featured":4107,"features":4108,"github":37,"manual":4128,"meta":4192,"modules":4193,"navigation":658,"order":4194,"path":32,"seo":4195,"slug":3692,"stem":4196,"summary":4197,"techStack":4198,"visible":658,"year":4203,"__hash__":4204},"projects\u002Fprojects\u002Fmorrow.md",{"type":7,"value":3727,"toc":4096},[3728,3731,3734,3741,3744,3747,3750,3757,3760,3780,3783,3790,3793,3796,3802,3808,3818,3824,3834,3840,3846,3852,3858,3861,3939,3942,4045,4048,4074,4077,4085,4088],[43,3729,3730],{"id":3730},"缘起",[13,3732,3733],{},"番茄钟、喝水提醒、拉伸提示，这些年装过不少，最后都关在同一个地方：通知权限。",[13,3735,3736,3737,3740],{},"不是它们提醒得不准。是",[23,3738,3739],{},"它们在该安静的时候响了","——改一个 bug 改到一半，全屏弹窗盖住代码；跟人共享屏幕讲方案，桌角突然冒出一只宠物、或者屏幕正中炸开一个\"该喝水了\"。健康提醒这个品类的失败，多数不是漏提醒，而是它自己变成了新的干扰源。",[13,3742,3743],{},"另一件事是我不太愿意为\"多喝水\"注册账号。想把这套事凑齐——喝水、起身、走动、看看远处——得装三个应用，每个都要登录，打卡数据全在别人的云上。为了记得喝水，把每天坐在电脑前的生活节奏上传出去，这笔交换不划算。",[13,3745,3746],{},"所以想做的东西很具体：一只住在桌角的宠物，把这几件事一起承担下来；该安静的时候它真的安静，数据一步都不出这台机器。",[43,3748,3749],{"id":3749},"背景",[13,3751,3752,3753,3756],{},"把桌面健康提醒这个赛道在 2026 年横向看了一遍（Stretchly、LookAway、Time Out、Shui、Viraam 这一类），有个结论比功能清单更重要：",[23,3754,3755],{},"这个品类的下限已经变了","。\"定时弹窗 + 跳过\u002F推迟\"不再够用，自动暂停、休息引导、常驻入口、统计，至少得占两项。",[13,3758,3759],{},"对照下来是三个缺口：",[466,3761,3762,3768,3774],{},[469,3763,3764,3767],{},[23,3765,3766],{},"免打扰基本只做到\"静默时段\"","：能设\"晚上十点后别吵\"，但不知道你现在在开会、在演示、还是根本不在座位上。而用户口碑里最被认可的恰恰是这一块。",[469,3769,3770,3773],{},[23,3771,3772],{},"响过之后没有下一步","：提醒弹出去了，但\"起身\"这件事本身没人管。用户点了\"知道了\"，然后继续坐着。",[469,3775,3776,3779],{},[23,3777,3778],{},"要么是单点计时器，要么是移动端记录本","：桌面端没有谁把\"喝水 \u002F 起身 \u002F 走动 \u002F 护眼\"这类多域编排做在一起，更没有人给它一个能长期看的情感载体。",[13,3781,3782],{},"这三条决定了产品要有哪些能力，也决定了它不该长成什么样——不做摄像头监测、不做强制锁屏的\"严格模式\"、不做云端统计。补基础能力的时候很容易补成\"又一个 Stretchly\"，那就没意义了。",[13,3784,3785,3786,3789],{},"v1.1 补的是第四个缺口，它不在上面三条里，是做完前面三轮才显出来的：",[23,3787,3788],{},"固定排程接不住\"你现在到底坐了多久\"","。每小时提醒一次，可能你刚站起来接完水回来它又响了；也可能你已经连着坐了九十分钟，而下一条提醒还在四十分钟之外。时间表是死的，人是活的。",[43,3791,3792],{"id":3792},"方案",[13,3794,3795],{},"按一个人在工位上的一天来组织：",[13,3797,3798,3801],{},[23,3799,3800],{},"装完就能开始。"," 首次打开只问三件事：你的工作时段、最想解决的两件事、挑一套现成计划。五个内置模板（久坐办公、会议密集日、备考冲刺、居家办公、护眼优先）都是确定性内容——不调用模型、不需要密钥、不联网。选中即复制成一份属于你的计划，之后在计划页逐条改间隔、时段与每日目标。想要更个性化的，再走\"用一段话描述处境 → 生成整套计划\"那条路。",[13,3803,3804,3807],{},[23,3805,3806],{},"提醒由一个角色承担，而不是一串弹窗。"," 桌角一只小宠物常驻，头顶一株芽按今天的完成率分档生长，当天生长、次晨归零。有待处理时它进等待姿态并冒出气泡；提醒清空后庆祝一下；休息时它陪着你倒计时。一眼扫过去就知道\"今天做得怎么样\"，不需要打开主窗口。",[13,3809,3810,3813,3814,3817],{},[23,3811,3812],{},"它会数你坐了多久，而不是只看表。"," 一个连续的用机时钟，切应用不清零——在三个编辑器之间来回切不算休息，得真的离开十五分钟以上才归零，锁屏也算休息。坐满一小时、而十五分钟内又没有正式站立提醒，它才开口一次：每段久坐最多两次、每天最多五次、两次之间至少隔半小时。真去休息过的，接下来十分钟内到点的站立提醒会静默跳过，不算错过也不进待处理；锁屏超过五分钟、且这次已经坐了半小时以上，解锁时自动算你完成一次。",[23,3815,3816],{},"提醒还是你的计划，久坐感知只做加减法","——不另起一套计时器，也不推翻你自己定的间隔。",[13,3819,3820,3823],{},[23,3821,3822],{},"它说话不好听，但每句都留台阶。"," 这一版把宠物人格从\"温和陪伴\"改成了\"毒舌健康搭子\"，默认就是损友式的那一档：坐满时会说\"椅子快以为你长在上面\"，周统计变差时会说\"椅子很高兴，你的腿未必\"。硬边界写在内容里：只吐槽久坐行为与统计，不评价外貌、智力、家庭与健康状况，不做健康判断，每句话后面必须跟一个能执行的台阶（起来走两步、再坐十分钟、今天先这样）。嫌难听随时能换——设置里三档（毒舌 \u002F 活泼 \u002F 温和），任何气泡里点一下「别这样说」就切成温和并记住，还有「今天先这样」让它当天闭嘴。",[13,3825,3826,3829,3830,3833],{},[23,3827,3828],{},"该安静的时候真的安静。"," 六路信号：连续几分钟没碰键盘鼠标、锁屏或休眠、会议应用在前台、全屏应用、系统专注、日历正在忙。每一路都能单独关。暂停期间到点的周期性提醒",[23,3831,3832],{},"顺延不补发","——恢复后按原间隔重新计时，而不是把错过的十来条一次性糊到脸上。",[13,3835,3836,3839],{},[23,3837,3838],{},"共享屏幕时不被看见。"," 演示模式一键进入：暂停提醒、隐藏宠物与遮罩，并在系统支持时把它们排除出屏幕共享与录屏。会议或全屏持续几秒后，宠物会自己提议进入；靠建议进入的，在会议结束后自动退出，手动进入的等你手动退出。退出后不补弹演示前的待处理。",[13,3841,3842,3845],{},[23,3843,3844],{},"响过之后要有下一步。"," 站立、走动、护眼三类提醒到点前一分钟先预警，到点进入倒计时覆盖层，配一条与类型匹配的动作：护眼是远眺加呼吸，站立是肩颈拉伸，走动是起身走动。倒计时结束自动记完成；提前结束可以选\"算完成\"或\"跳过\"。喝水、如厕这类保持简单打卡，不硬塞一套流程。",[13,3847,3848,3851],{},[23,3849,3850],{},"错过的要看得见，但不补弹。"," 一次性提醒（\"明天下午三点交材料\"）独立于场景，关掉某个习惯场景也不会让它消失；错过了记成\"已错过\"，留在首页，不再响。",[13,3853,3854,3857],{},[23,3855,3856],{},"进度要有据可查。"," 每条提醒都能看到今日进度与连续达标天数；首页一屏读完待处理、已错过、今日进度与下一次触发的倒计时；久坐页能看到今天一共坐了多久、最长一口气坐了多久、主动起来几次，最近七天可以往回翻。",[43,3859,3860],{"id":3860},"技术架构",[61,3862,3863,3873],{},[64,3864,3865],{},[67,3866,3867,3870],{},[70,3868,3869],{},"模块",[70,3871,3872],{},"技术选型",[77,3874,3875,3883,3891,3899,3907,3915,3923,3931],{},[67,3876,3877,3880],{},[82,3878,3879],{},"桌面运行时",[82,3881,3882],{},"Wails v3（beta.23），WebView2 \u002F WKWebView",[67,3884,3885,3888],{},[82,3886,3887],{},"后端",[82,3889,3890],{},"Go 1.25 + GORM + SQLite（纯 Go 驱动，交叉编译不带 CGO）",[67,3892,3893,3896],{},[82,3894,3895],{},"前端",[82,3897,3898],{},"Vue3 + Vite + Element Plus + Pinia，hash 路由",[67,3900,3901,3904],{},[82,3902,3903],{},"提醒引擎",[82,3905,3906],{},"纯时间计算 + 调度循环 + 一次性提醒全局扫描三层分离",[67,3908,3909,3912],{},[82,3910,3911],{},"久坐感知",[82,3913,3914],{},"全局连续时钟 + 分级空闲判定（5 \u002F 15 分钟两档）+ 与提醒计划的加减法",[67,3916,3917,3920],{},[82,3918,3919],{},"管道契约",[82,3921,3922],{},"错误码 \u002F 分页 \u002F 事件三类常量在 Go 单一真相，前端只做镜像",[67,3924,3925,3928],{},[82,3926,3927],{},"工程化",[82,3929,3930],{},"Makefile 统一入口 + 架构护栏 + 依赖登记制 + OpenSpec 治理",[67,3932,3933,3936],{},[82,3934,3935],{},"交付",[82,3937,3938],{},"macOS 美化 dmg \u002F Windows NSIS 安装器，CI 覆盖 macOS 与 Windows",[13,3940,3941],{},"几个值得说的做法：",[466,3943,3944,3964,3976,3994,4004,4010,4016,4030,4036],{},[469,3945,3946,3949,3950,3952,3953,3956,3957,3959,3960,3963],{},[23,3947,3948],{},"单进程交付","：根包用 ",[128,3951,279],{}," 把前端产物嵌进二进制，用户拿到的就是一个可执行体；",[128,3954,3955],{},"frontend\u002Fdist"," 不入库，",[128,3958,3318],{}," 会先检查它存不存在并给出修复命令，而不是让你对着 ",[128,3961,3962],{},"pattern all:frontend\u002Fdist: no matching files found"," 猜。",[469,3965,3966,3969,3970,3972,3973,3975],{},[23,3967,3968],{},"分层由护栏强制","：绑定方法层（",[128,3971,134],{},"）不许 import gorm 或 database，必须经 service；model 层是叶子。越界会在 ",[128,3974,3351],{}," 阶段变红，不靠 code review 记性。",[469,3977,3978,3985,3986,3989,3990,3993],{},[23,3979,3980,3981,3984],{},"一次性提醒复用了 ",[128,3982,3983],{},"reminders"," 表","：加一列 ",[128,3987,3988],{},"once_at","（0 表示周期项）、",[128,3991,3992],{},"scenario_id"," 置空、按绝对时刻全局扫描。没有另建一张表加一套状态机——完成\u002F跳过\u002F推迟\u002F去重的逻辑写两遍，迟早漂移。",[469,3995,3996,3999,4000,4003],{},[23,3997,3998],{},"久坐时钟是全局的，不是按应用的","：切窗口不重置计时，只有满十五分钟的空闲才归零，锁屏单独作为休息信号。否则\"我在三个窗口之间来回切\"会被读成\"我休息了三次\"，而这个错误的表现是",[23,4001,4002],{},"它从此再也不提醒你","——不报错、不崩溃，只是默默失效。",[469,4005,4006,4009],{},[23,4007,4008],{},"同一句话只有一处出处","：休息预警在 60 秒里每一秒都会被读一次，剩 8 秒还说\"1 分钟后\"就是假话，所以措辞按剩余时间分三档（1 分钟 \u002F 半分钟 \u002F 马上）。分档只改措辞、不改投递时刻，且整仓只有一张表和一处函数，宠物气泡与菜单栏共用同一份快照，越界副本由护栏拦住。",[469,4011,4012,4015],{},[23,4013,4014],{},"六路免打扰信号只读本机信息","：空闲时长、锁屏状态、前台应用身份、全屏几何、系统专注与日历忙闲的布尔值。不读屏幕内容、不读窗口标题、不读日历事件详情、不碰摄像头麦克风。",[469,4017,4018,4021,4022,4025,4026,4029],{},[23,4019,4020],{},"窗口捕获排除","用系统能力兜底而不是更聪明的检测：macOS 走 ",[128,4023,4024],{},"NSWindow.sharingType = .none","，Windows 走 ",[128,4027,4028],{},"SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)","；Linux 是 no-op，界面如实降级，不假装支持。",[469,4031,4032,4035],{},[23,4033,4034],{},"宠物气泡出现时临时扩窗","：底边不动、向上与两侧扩，以脚底所在屏幕的工作区夹取；退出时持久化的是基础几何而不是扩出来的矩形——否则下次启动宠物会凭空下移一段。",[469,4037,4038,172,4041,4044],{},[23,4039,4040],{},"开发态有一条给 Agent 用的通道",[128,4042,4043],{},"make dev-mcp"," 会在本机回环上开一个 MCP 服务，能调绑定方法、读 DOM、等事件。它只能在显式开启时存在，默认构建里一行代码都没有，由源码层、机制层、产物层三道检查共同保证。",[43,4046,4047],{"id":4047},"成果",[466,4049,4050,4056,4062,4068],{},[469,4051,4052,4055],{},[23,4053,4054],{},"能交付，不是能跑 demo","：macOS 能打出带拖拽引导的美化 dmg，Windows 能打出免管理员的 NSIS 安装器；目标机器不需要 Go、Node 或数据库。两个版本都打了 tag（v1.0.0 \u002F v1.1.0）。",[469,4057,4058,4061],{},[23,4059,4060],{},"产出是可数的","：从空目录到可打包安装，21 次提交、65 张实现票、224 个源文件共 45,036 行（Go 33,286 + 前端 11,750）；394 个测试函数、12 份架构决策记录、7 份能力规格。",[469,4063,4064,4067],{},[23,4065,4066],{},"自用中","：现在每天由它管喝水、起身与久坐。它唯一让我愿意多点一下\"完成\"的地方是那株芽——进度从\"一个数字\"变成了\"一个会长大的东西\"。而久坐感知上线之后，最直接的变化是我开始在意\"最长一口气坐了多久\"这个数字。",[469,4069,4070,4073],{},[23,4071,4072],{},"没对外发布","：目前只有我自己在用，没有用户数可以报。这一栏我不打算填一个好看的假数字。",[43,4075,4076],{"id":4076},"体验",[13,4078,4079,4080,4084],{},"源码在 ",[17,4081,4083],{"href":37,"rel":4082},[39],"github.com\u002FBenny66\u002Fmorrow","，README 里有构建步骤与截图。它是本地优先的桌面应用，没有可点的网页版 demo——想用起来，要么装一个安装包，要么照 README 从源码构建一次。",[13,4086,4087],{},"想验两件事的话：",[1410,4089,4090,4093],{},[469,4091,4092],{},"把喝水提醒的间隔设成两分钟，然后锁屏。等锁屏结束回来，你不会看到一串补发的弹窗——它们被顺延了，不补发。",[469,4094,4095],{},"什么都不做，连坐一小时。如果这一小时里没有正式的站立提醒，桌角那只小水滴会开口损你一句，然后等你选\"起来走走\"。",{"title":403,"searchDepth":564,"depth":564,"links":4097},[4098,4099,4100,4101,4102,4103],{"id":3730,"depth":564,"text":3730},{"id":3749,"depth":564,"text":3749},{"id":3792,"depth":564,"text":3792},{"id":3860,"depth":564,"text":3860},{"id":4047,"depth":564,"text":4047},{"id":4076,"depth":564,"text":4076},"desktop","个人项目","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fmorrow\u002Fmorrow-dashboard.webp",false,[4109,4112,4116,4120,4124],{"title":4110,"description":4111,"image":4106},"仪表盘","当前计划、待处理提醒、今日进度与下一次触发，一屏读完",{"title":4113,"description":4114,"image":4115},"场景模板","五套内置计划，选中即复制成可编辑的一份，不联网也不用密钥","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fmorrow\u002Fmorrow-templates.webp",{"title":4117,"description":4118,"image":4119},"三步引导","工作时段、两个关注目标、一套模板，问完就有一份能启用的计划","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fmorrow\u002Fmorrow-onboarding.webp",{"title":4121,"description":4122,"image":4123},"可编辑计划","每条提醒的间隔、时段与每日目标都能逐条调整","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fmorrow\u002Fmorrow-plan.webp",{"title":4125,"description":4126,"image":4127},"本地优先的架构","Go 服务层 + Vue 渲染 + 本地 SQLite，前端产物直接嵌进二进制","https:\u002F\u002Fname.os.bennywen.xyz\u002Fbennywen.xyz\u002Fassets\u002Fmorrow\u002Fmorrow-architecture.webp",{"specs":4129,"modules":4153,"deploy":4182,"limits":4186},[4130,4133,4136,4139,4141,4144,4147,4150],{"label":4131,"value":4132},"交付形态","一个安装包装进系统就能用，不需要另装运行环境，也不依赖外部数据库服务",{"label":4134,"value":4135},"数据存放","提醒、打卡、久坐统计与统计全部留在本机一个数据文件里；没有账号、没有云同步，不需要注册",{"label":4137,"value":4138},"提醒类型","周期性（每天某个时段内按间隔重复）、每日单点（每天同一时刻一次）、一次性（指定某天某时，只响一次）",{"label":3911,"value":4140},"一个连续的用机时钟（切应用不清零，离开 15 分钟以上才归零）；坐满 60 分钟且 15 分钟内没有正式站立提醒，宠物才开口一次，每天最多 5 次",{"label":4142,"value":4143},"宠物人格","默认「毒舌健康搭子」，另有活泼与温和两档；任何气泡里都能一键切换并记住偏好",{"label":4145,"value":4146},"免打扰信号","空闲、锁屏休眠、会议应用在前台、全屏、系统专注、日历忙闲，六路各自可单独关闭",{"label":4148,"value":4149},"上手成本","干净安装后三步（工作时段 → 两个关注目标 → 场景模板）就能得到一套可编辑的计划，全程不联网",{"label":4151,"value":4152},"适用规模","面向一个人的工位，单机单用户",[4154,4157,4159,4162,4165,4167,4170,4173,4176,4179],{"name":4155,"desc":4156},"提醒与计划","每天几点到几点、隔多久提醒一次、每天目标几次，逐条可改；也能建只响一次的事",{"name":4113,"desc":4158},"久坐办公、会议密集日、备考冲刺、居家办公、护眼优先五套现成计划，选中即复制成自己的，之后随便改",{"name":4160,"desc":4161},"首次引导","装完只问三件事——工作时段、最想解决的两件事、用哪套模板，问完就有一套能用计划",{"name":4163,"desc":4164},"智能免打扰","离开座位、锁屏、开会、全屏、系统专注或日历正忙时不打扰；恢复后不把错过的攒一堆一次性弹回来",{"name":3911,"desc":4166},"只认真的休息，不看固定排程：离开十五分钟才算歇过，锁屏也算；坐了多久它心里有数",{"name":4168,"desc":4169},"休息模式","站立、走动、护眼到点前先提醒一句，到点进入倒计时，配一条对应的动作（远眺呼吸、肩颈拉伸、起身走动）",{"name":4171,"desc":4172},"演示模式","讲方案、共享屏幕时一键安静，宠物与遮罩不进共享画面；系统不支持时如实说明，不假装生效",{"name":4174,"desc":4175},"桌面宠物","桌角一只有名字的小宠物，头顶的芽按今天完成率长；待处理、休息陪做、暂停原因都由它说话",{"name":4177,"desc":4178},"毒舌人格","坐太久它会开口损你两句，每句都留一个能执行的台阶；嫌难听可以立刻切成温和",{"name":4180,"desc":4181},"久坐页","今天坐了多久、最长一口气多久、主动起来几次，最近七天可以翻",[4183,4184,4185],"要什么环境：macOS 12 及以上，或 Windows 10\u002F11；装完即可用，不需要 Go、Node 或数据库","多久起得来：首次打开走三步向导，选完工作时段与模板就有一套可启用的计划；全程不需要联网，也不需要模型密钥","怎么维护：数据与配置都在用户目录同一处，备份就是拷这个目录；应用内也提供数据库导出与恢复",[4187,4188,4189,4190,4191],"不做医疗建议：只覆盖喝水、起身、走动、用眼放松、如厕这类日常保健，不涉及疾病诊断、用药方案、孕期或慢病管理","只有本机这一份数据：没有账号与云同步，换机器要自己导出导入；也没有手机端","应用画像默认关闭：它可以选择记录你大概在做什么类别（写代码、做设计、浏览、沟通、影音、其他），但要单独授权才开；只存应用身份与时长，不读窗口标题、不截图、不上传","毒舌有硬边界：只吐槽久坐行为与统计，不评价外貌、智力、家庭与健康状况，不做健康判断，每句都留台阶；嫌难听可以换成温和","AI 是可选入口：想让模型帮你生成计划、或从一段话\u002F一张图里解析出提醒，需要自备一个兼容接口的模型服务；不用它，模板与手动添加全部可用",{},10,19,{"title":33,"description":403},"projects\u002Fmorrow","把「该喝水、该起身」交给桌角一只会跟着完成率长芽的宠物——它会数你坐了多久、该安静的时候真的安静，提醒与打卡一步都不离开这台机器",[219,186,4199,4200,4201,4202],"Vue3","Element Plus","SQLite","GORM","2026","XAwY8Y_AuO2mSJL144_vrEMiycHDrDOr7KvqWyL6m8A",1792857942018]