From ccab502d1cce8fecaf5e9b1768178f36f4f609b4 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 17 Oct 2026 15:47:18 -0300 Subject: [PATCH] feat(native): open the native window on every release, with an experimental notice The first launch of each release chooses the native window (falling back to the web one where it cannot start), so every update is tried on it while it is experimental. A dev build and an install without lich-native are left alone. The first native window a user opens says it is experimental and links to a bug report. --- CHANGELOG.md | 5 ++ internal/system/native.go | 14 ++++ internal/system/system_test.go | 18 +++++ main.go | 23 ++++++ native/cmd/lich-native/experimental_notice.go | 75 +++++++++++++++++++ native/cmd/lich-native/main.go | 6 ++ native/i18n/locales/en/shell.json | 6 ++ native/i18n/locales/es/shell.json | 6 ++ native/i18n/locales/ja/shell.json | 6 ++ native/i18n/locales/ko/shell.json | 6 ++ native/i18n/locales/pt-br/shell.json | 6 ++ native/i18n/locales/ru/shell.json | 6 ++ native/i18n/locales/zh-cn/shell.json | 6 ++ 13 files changed, 183 insertions(+) create mode 100644 native/cmd/lich-native/experimental_notice.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8e64d0..3887b2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Every update opens the native window.** The first launch of each release + switches lich to the native window, falling back to the web one where it + cannot start; switch back in Settings and lich stays on the web window until + the next release. The first time the native window opens, it says it is + experimental and links to a bug report. - **A lighter status ring in the native window.** The ring around a session card's icon is drawn thinner, to read at the weight the web window gives it. diff --git a/internal/system/native.go b/internal/system/native.go index 4ade700d..af6c5c92 100644 --- a/internal/system/native.go +++ b/internal/system/native.go @@ -14,8 +14,22 @@ const ( WindowWeb = "web" WindowNative = "native" WindowKindKey = "window.kind" + // NativeReleaseKey is the release that last put lich on the native window. + NativeReleaseKey = "window.nativeRelease" ) +// devVersion is main.version in a build without a release tag. +const devVersion = "dev" + +// SwitchesToNative reports whether a launch of version puts lich on the +// native window, whatever was chosen before: once per release, so every +// update is tried on it while it is experimental, and a user who switched +// back keeps the web window until the next one. A dev build never does, nor +// an install without the native binary, which would only fall back. +func SwitchesToNative(version, lastSwitched string, nativeShipped bool) bool { + return nativeShipped && version != devVersion && version != lastSwitched +} + // NativeHooks are how lich drives the native window (`lich native`), wired by // main. type NativeHooks struct { diff --git a/internal/system/system_test.go b/internal/system/system_test.go index e5522553..217967b8 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -600,3 +600,21 @@ func TestNativeWindowThatCannotStartFallsBackToTheWebOne(t *testing.T) { t.Fatalf("launch failing: err %v, web shown %d; want the web window", err, r.shown) } } + +func TestSwitchesToNativeOncePerRelease(t *testing.T) { + cases := []struct { + name, version, last string + shipped, want bool + }{ + {"first launch of a release", "0.69.0", "", true, true}, + {"an update", "0.69.0", "0.68.0", true, true}, + {"the same release again", "0.69.0", "0.69.0", true, false}, + {"no native binary", "0.69.0", "0.68.0", false, false}, + {"a dev build", "dev", "", true, false}, + } + for _, c := range cases { + if got := SwitchesToNative(c.version, c.last, c.shipped); got != c.want { + t.Errorf("%s: SwitchesToNative(%q, %q, %v) = %v, want %v", c.name, c.version, c.last, c.shipped, got, c.want) + } + } +} diff --git a/main.go b/main.go index c4e700c1..3e1ddb5b 100644 --- a/main.go +++ b/main.go @@ -367,6 +367,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub Close: func() { hub.Emit(windowCloseEvent, nil) }, Locate: func() (string, error) { return cli.NativeExecutable(os.Getenv) }, }) + switchToNativeOnRelease(db) sys.SetWindowChoice(func() string { return windowChoice(db) }) dispatcher.Register("system", sys) // Deleting a session for good takes any prompt parked on it, and the row is @@ -951,6 +952,28 @@ func windowChoice(db *store.Service) string { return kind } +// switchToNativeOnRelease chooses the native window on the first launch of a +// release (system.SwitchesToNative). Failing to write leaves the choice the +// user had. +func switchToNativeOnRelease(db *store.Service) { + last, err := db.GetSetting(system.NativeReleaseKey, "") + if err != nil { + slog.Warn("read the release that last chose the native window", "err", err) + return + } + _, locateErr := cli.NativeExecutable(os.Getenv) + if !system.SwitchesToNative(version, last, locateErr == nil) { + return + } + if err := db.SetSetting(system.WindowKindKey, "", system.WindowNative); err != nil { + slog.Warn("choose the native window for this release", "err", err) + return + } + if err := db.SetSetting(system.NativeReleaseKey, "", version); err != nil { + slog.Warn("record the release that chose the native window", "err", err) + } +} + // showWindow opens the chosen window where nobody waits on the answer but // the log: the launch and the tray. func showWindow(sys *system.Service, from string) { diff --git a/native/cmd/lich-native/experimental_notice.go b/native/cmd/lich-native/experimental_notice.go new file mode 100644 index 00000000..73d43479 --- /dev/null +++ b/native/cmd/lich-native/experimental_notice.go @@ -0,0 +1,75 @@ +package main + +import ( + "flag" + "log" + "sync/atomic" + + "gioui.org/layout" + "gioui.org/widget" + + "github.com/omartelo/lich/native/i18n" + "github.com/omartelo/lich/native/ui" +) + +var flagExperimentalNotice = flag.Bool("experimental-notice", false, "open the experimental-window notice at start, for -shot") + +// experimentalNoticeSeenKey is set once the notice was closed, a global +// setting: the notice greets the first native window a user opens, not every +// release that switches them to it. +const experimentalNoticeSeenKey = "native.experimentalNoticeSeen" + +// experimentalNotice tells a user the native window is experimental and asks +// for an issue on anything that breaks. +type experimentalNotice struct { + open atomic.Bool + dialog ui.DialogState + gotIt, report widget.Clickable +} + +// checkExperimentalNotice opens the notice when it was never closed. +func (u *window) checkExperimentalNotice() { + seen, err := u.client.GetSetting(u.ctx, experimentalNoticeSeenKey, "") + if err != nil { + log.Printf("experimental notice: %v", err) + return + } + if seen == "" { + u.beta.open.Store(true) + u.win.Invalidate() + } +} + +func (u *window) closeExperimentalNotice() { + u.beta.open.Store(false) + go func() { + u.logErr("experimental notice: record", u.client.SetSetting(u.ctx, experimentalNoticeSeenKey, "", "1")) + }() +} + +func (u *window) layoutExperimentalNotice(gtx C, th *ui.Theme) { + n := &u.beta + if n.gotIt.Clicked(gtx) { + u.closeExperimentalNotice() + } + if n.report.Clicked(gtx) { + u.closeExperimentalNotice() + go func() { + d, err := u.client.Diagnostics(u.ctx) + if err != nil { + log.Printf("experimental notice: diagnostics: %v", err) + return + } + u.logErr("experimental notice: open bug report", u.client.OpenExternal(u.ctx, bugReportURL(d))) + }() + } + on := th.On(th.Popover) + gotIt := on.Button(&n.gotIt, i18n.T("shell.experimentalNotice.gotIt")) + gotIt.Variant = ui.VariantGhost + d := th.Dialog(&n.dialog, n.open.Load(), i18n.T("shell.experimentalNotice.title"), i18n.T("shell.experimentalNotice.description")) + d.MaxWidth = optInWidth + d.Actions = []layout.Widget{gotIt.Layout, on.Button(&n.report, i18n.T("shell.experimentalNotice.reportIssue")).Layout} + if d.Layout(gtx) { + u.closeExperimentalNotice() + } +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index e5c401d7..d1c4dba6 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -135,6 +135,7 @@ type window struct { notifier *notifier optIn notifyOptIn gitMissing gitMissing + beta experimentalNotice setup providerSetup plugin pluginGate appUpdate appUpdateGate @@ -414,6 +415,10 @@ func (u *window) connect() error { go u.checkGit(false) } go u.settings.startupPatchNotes() + u.beta.open.Store(*flagExperimentalNotice) + if !*flagExperimentalNotice { + go u.checkExperimentalNotice() + } go u.pollAppUpdate() return nil } @@ -471,6 +476,7 @@ func (u *window) layout(gtx C) error { u.layoutClosePrompt(gtx, u.theme) u.layoutNotifyOptIn(gtx, u.theme) u.layoutGitMissing(gtx, u.theme) + u.layoutExperimentalNotice(gtx, u.theme) var edge int edge, act, contentErr = u.body(gtx, v) u.sidebar.flight(gtx, u.theme, u.model) diff --git a/native/i18n/locales/en/shell.json b/native/i18n/locales/en/shell.json index 83c1fa55..b0d4a943 100644 --- a/native/i18n/locales/en/shell.json +++ b/native/i18n/locales/en/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Hands-on time", "handsOnFailed": "Could not read hands-on time." }, + "experimentalNotice": { + "title": "This window is experimental", + "description": "The native window is new and still experimental. If anything breaks or looks wrong, please open an issue and tell us: it is how we find out what to fix.", + "gotIt": "Got it", + "reportIssue": "Report an issue" + }, "gitMissingGate": { "title": "git is not installed", "description": "lich reads every branch, diff and worktree through git. Sessions still run without it — the version control surfaces stay empty.", diff --git a/native/i18n/locales/es/shell.json b/native/i18n/locales/es/shell.json index 1691f552..67626850 100644 --- a/native/i18n/locales/es/shell.json +++ b/native/i18n/locales/es/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Tiempo activo", "handsOnFailed": "No se pudo leer el tiempo activo." }, + "experimentalNotice": { + "title": "Esta ventana es experimental", + "description": "La ventana nativa es nueva y todavía experimental. Si algo falla o se ve mal, por favor, abre un issue y cuéntanoslo: así sabemos qué corregir.", + "gotIt": "Entendido", + "reportIssue": "Reportar un problema" + }, "gitMissingGate": { "title": "git no está instalado", "description": "lich lee cada rama, diff y worktree a través de git. Las sesiones siguen funcionando sin él, pero las superficies de control de versiones quedan vacías.", diff --git a/native/i18n/locales/ja/shell.json b/native/i18n/locales/ja/shell.json index a66a9381..18b6b1ad 100644 --- a/native/i18n/locales/ja/shell.json +++ b/native/i18n/locales/ja/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "作業時間", "handsOnFailed": "作業時間を取得できませんでした。" }, + "experimentalNotice": { + "title": "このウィンドウは試験的です", + "description": "ネイティブウィンドウは新しく、まだ試験段階です。何かが壊れたり、おかしく見えたりしたら、ぜひ issue を作成して教えてください。修正すべき点を知る手がかりになります。", + "gotIt": "了解", + "reportIssue": "問題を報告" + }, "gitMissingGate": { "title": "git がインストールされていません", "description": "lich はブランチ、差分、ワークツリーをすべて git で読み取ります。git がなくてもセッションは動きますが、バージョン管理の画面は空のままです。", diff --git a/native/i18n/locales/ko/shell.json b/native/i18n/locales/ko/shell.json index 2b1a0aa3..593d6f93 100644 --- a/native/i18n/locales/ko/shell.json +++ b/native/i18n/locales/ko/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "직접 작업 시간", "handsOnFailed": "직접 작업 시간을 읽을 수 없습니다." }, + "experimentalNotice": { + "title": "이 창은 실험적입니다", + "description": "네이티브 창은 새로 나온 실험적 기능입니다. 문제가 생기거나 이상하게 보이면 꼭 이슈를 열어 알려 주세요. 무엇을 고쳐야 할지 알 수 있는 방법입니다.", + "gotIt": "확인", + "reportIssue": "문제 보고" + }, "gitMissingGate": { "title": "git이 설치되어 있지 않습니다", "description": "lich는 모든 브랜치, diff, worktree를 git으로 읽습니다. git이 없어도 세션은 실행되지만, 버전 관리 화면은 비어 있습니다.", diff --git a/native/i18n/locales/pt-br/shell.json b/native/i18n/locales/pt-br/shell.json index 84ec7bb0..15e6e831 100644 --- a/native/i18n/locales/pt-br/shell.json +++ b/native/i18n/locales/pt-br/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Tempo de interação", "handsOnFailed": "Não foi possível ler o tempo de interação." }, + "experimentalNotice": { + "title": "Esta janela é experimental", + "description": "A janela nativa é nova e ainda experimental. Se algo quebrar ou parecer errado, por favor, abra uma issue e conte pra gente: é assim que descobrimos o que corrigir.", + "gotIt": "Entendi", + "reportIssue": "Reportar um problema" + }, "gitMissingGate": { "title": "O git não está instalado", "description": "O lich lê cada branch, diff e worktree pelo git. As sessões continuam funcionando sem ele, mas as telas de controle de versão ficam vazias.", diff --git a/native/i18n/locales/ru/shell.json b/native/i18n/locales/ru/shell.json index c1a95120..f2f6352a 100644 --- a/native/i18n/locales/ru/shell.json +++ b/native/i18n/locales/ru/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "Время за работой", "handsOnFailed": "Не удалось прочитать время за работой." }, + "experimentalNotice": { + "title": "Это окно экспериментальное", + "description": "Нативное окно новое и пока экспериментальное. Если что-то сломается или выглядит не так, пожалуйста, создайте issue и расскажите нам: так мы узнаём, что исправить.", + "gotIt": "Понятно", + "reportIssue": "Сообщить о проблеме" + }, "gitMissingGate": { "title": "git не установлен", "description": "lich читает все ветки, изменения и worktree через git. Сессии работают и без него, но разделы контроля версий остаются пустыми.", diff --git a/native/i18n/locales/zh-cn/shell.json b/native/i18n/locales/zh-cn/shell.json index 8552c93b..262412bc 100644 --- a/native/i18n/locales/zh-cn/shell.json +++ b/native/i18n/locales/zh-cn/shell.json @@ -51,6 +51,12 @@ "handsOnTime": "亲自操作时长", "handsOnFailed": "无法读取亲自操作时长。" }, + "experimentalNotice": { + "title": "此窗口为实验性功能", + "description": "原生窗口是新功能,仍处于实验阶段。如果遇到任何故障或显示异常,请务必提交 issue 告诉我们:这是我们了解需要修复什么的方式。", + "gotIt": "知道了", + "reportIssue": "报告问题" + }, "gitMissingGate": { "title": "未安装 git", "description": "lich 通过 git 读取每个分支、差异和工作树。没有 git 时会话仍可运行,但版本控制相关界面会保持为空。",