From 23219efb7849d1014b011742399a191a702b945b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:47:16 +0000 Subject: [PATCH 1/2] Initial plan From 7b7b5727ec158a2da85aacece3c259c319d2b7c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:55:53 +0000 Subject: [PATCH 2/2] feat: add startup update check setting Agent-Logs-Url: https://github.com/harumiWeb/eitango/sessions/8ac3bf44-5877-4dc8-aa20-3dc32dd8ecc4 Co-authored-by: harumiWeb <164025931+harumiWeb@users.noreply.github.com> --- CHANGELOG.md | 10 ++++- README.en.md | 2 +- README.md | 2 +- assets/locale/en.toml | 1 + assets/locale/ja.toml | 1 + internal/app/cmds.go | 4 +- internal/app/cmds_test.go | 14 ++++++- internal/app/model.go | 7 +++- internal/app/update.go | 4 ++ internal/app/update_test.go | 73 ++++++++++++++++++++++++++++++++++ internal/app/view.go | 2 + internal/app/view_test.go | 2 + internal/config/config.go | 9 +++++ internal/config/config_test.go | 5 +++ internal/i18n/i18n_test.go | 1 + internal/i18n/keys.go | 1 + 16 files changed, 131 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f319024..28d6fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ## [Unreleased] +## [0.7.2] - 2026-04-11 + +### Changed + +- ホーム設定 overlay と `config.toml` に `startup_update_check` を追加し、起動時の update check をユーザー設定で ON/OFF できるようにしました。`eitango version` による手動確認は従来どおり使えます。 + ## [0.7.1] - 2026-04-11 ### Added @@ -194,7 +200,9 @@ - 通知不要時に古い update tag が画面に残る問題を修正しました。 - `dev` など非 semver の build でも update availability を正しく判定するようにしました。 -[Unreleased]: https://github.com/harumiWeb/eitango/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/harumiWeb/eitango/compare/v0.7.2...HEAD +[0.7.2]: https://github.com/harumiWeb/eitango/compare/v0.7.1...v0.7.2 +[0.7.1]: https://github.com/harumiWeb/eitango/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/harumiWeb/eitango/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/harumiWeb/eitango/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/harumiWeb/eitango/compare/v0.5.2...v0.6.0 diff --git a/README.en.md b/README.en.md index 1a75a49..952b4d8 100644 --- a/README.en.md +++ b/README.en.md @@ -309,7 +309,7 @@ Regular study sessions are local-first and use the local SQLite database. Networ - update checks are optional helper behavior, not a requirement for starting or continuing a study session - the request is only used to fetch lightweight release metadata such as the latest version and release URL -- the home-screen notice revalidates the latest release asynchronously on every launch +- the home-screen notice revalidates the latest release asynchronously on every launch by default, and you can toggle it via the settings screen or `startup_update_check` in `config.toml` - the first successful check seeds the cache without showing a notice - `update-check.json` stores the most recent successful result and is used as a fallback when the request times out or fails - later launches show a lightweight home-screen notice when a newer version is available diff --git a/README.md b/README.md index c1cec1a..98f78a0 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ write_quit = ["esc"] - 更新チェックは補助機能であり、学習開始や回答処理の必須要件ではありません - 取得するのは主に最新 release の version / URL などの更新案内に必要な情報です -- ホーム画面の通知は起動ごとに非同期で latest release を再確認します +- ホーム画面の通知は既定では起動ごとに非同期で latest release を再確認し、ホーム設定または `config.toml` の `startup_update_check` で ON/OFF できます - 初回の成功確認では通知せず、次回以降の起動で差分があればホーム画面に軽く表示します - `update-check.json` には直前の successful check 結果を保存し、タイムアウトやオフライン時の fallback に使います - `eitango version` は現在の build info に加えて latest release URL も表示します diff --git a/assets/locale/en.toml b/assets/locale/en.toml index a80aab7..8ef412b 100644 --- a/assets/locale/en.toml +++ b/assets/locale/en.toml @@ -28,6 +28,7 @@ questions = "Default questions" write_difficulty = "Write difficulty" write_difficulty_basic = "basic" write_difficulty_hard = "hard" +update_check = "Startup update check" audio_enabled = "Audio" audio_voice = "Local voice" audio_voice_auto = "auto" diff --git a/assets/locale/ja.toml b/assets/locale/ja.toml index b28ee8a..4c4ecad 100644 --- a/assets/locale/ja.toml +++ b/assets/locale/ja.toml @@ -28,6 +28,7 @@ questions = "既定の問題数" write_difficulty = "Write難易度" write_difficulty_basic = "basic" write_difficulty_hard = "hard" +update_check = "起動時更新チェック" audio_enabled = "音声" audio_voice = "ローカル音声" audio_voice_auto = "自動" diff --git a/internal/app/cmds.go b/internal/app/cmds.go index bec9c99..43f0827 100644 --- a/internal/app/cmds.go +++ b/internal/app/cmds.go @@ -54,8 +54,8 @@ func loadStatsCmd(st *store.Store) tea.Cmd { } } -func updateCheckCmd(service updatecheck.Service, currentVersion string) tea.Cmd { - if service == nil { +func updateCheckCmd(service updatecheck.Service, currentVersion string, enabled bool) tea.Cmd { + if service == nil || !enabled { return nil } return func() tea.Msg { diff --git a/internal/app/cmds_test.go b/internal/app/cmds_test.go index 536756b..be8c7b9 100644 --- a/internal/app/cmds_test.go +++ b/internal/app/cmds_test.go @@ -616,7 +616,7 @@ func TestUpdateCheckCmdUsesCheckNowAndReturnsResultEvenWhenServiceErrors(t *test checkNowErr: errors.New("timeout"), } - msg := updateCheckCmd(service, "v1.1.0")() + msg := updateCheckCmd(service, "v1.1.0", true)() checked, ok := msg.(updateCheckedMsg) if !ok { t.Fatalf("updateCheckCmd() returned %T, want updateCheckedMsg", msg) @@ -632,6 +632,18 @@ func TestUpdateCheckCmdUsesCheckNowAndReturnsResultEvenWhenServiceErrors(t *test } } +func TestUpdateCheckCmdReturnsNilWhenDisabled(t *testing.T) { + t.Parallel() + + service := &stubUpdateService{} + if cmd := updateCheckCmd(service, "v1.1.0", false); cmd != nil { + t.Fatalf("updateCheckCmd() = %v, want nil", cmd) + } + if service.checkCalls != 0 || service.checkNowCalls != 0 { + t.Fatalf("service calls = check:%d checkNow:%d, want 0/0", service.checkCalls, service.checkNowCalls) + } +} + func newTestStore(t *testing.T) *store.Store { t.Helper() diff --git a/internal/app/model.go b/internal/app/model.go index cabcf34..2a2504e 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -33,6 +33,7 @@ const ( const ( settingsRowQuestionCount = iota settingsRowWriteDifficulty + settingsRowUpdateCheck settingsRowAudioEnabled settingsRowAudioVoice settingsRowAudioAutoplay @@ -189,6 +190,7 @@ type RootModel struct { settingsInput string settingsEditing bool settingsWriteDifficulty string + settingsUpdateCheckEnabled bool settingsAudioEnabled bool settingsAudioVoice string settingsAudioAutoplay bool @@ -260,7 +262,7 @@ func NewModel(store *store.Store, options Options) RootModel { func (m RootModel) Init() tea.Cmd { cmds := make([]tea.Cmd, 0, 2) - if cmd := updateCheckCmd(m.updateService, m.currentVersion); cmd != nil { + if cmd := updateCheckCmd(m.updateService, m.currentVersion, m.settings.UpdateCheckEnabled); cmd != nil { cmds = append(cmds, cmd) } if m.startup != nil { @@ -316,6 +318,7 @@ func (m RootModel) prepareSettingsOverlay() RootModel { m.settingsInput = strconv.Itoa(m.settings.SessionSize) m.settingsEditing = false m.settingsWriteDifficulty = config.NormalizeWriteModeDifficulty(m.settings.WriteModeDifficulty) + m.settingsUpdateCheckEnabled = m.settings.UpdateCheckEnabled m.settingsAudioEnabled = m.settings.AudioEnabled m.settingsAudioVoices = nil m.settingsAudioVoicesLoaded = false @@ -464,6 +467,7 @@ func (m RootModel) settingsDraft() (config.Settings, bool, bool) { draft := m.settings draft.SessionSize = count draft.WriteModeDifficulty = config.NormalizeWriteModeDifficulty(m.settingsWriteDifficulty) + draft.UpdateCheckEnabled = m.settingsUpdateCheckEnabled draft.AudioEnabled = m.settingsAudioEnabled draft.AudioVoice = m.settingsAudioVoice draft.AudioAutoplay = m.settingsAudioAutoplay && m.settingsAudioAvailable() @@ -509,6 +513,7 @@ func (m RootModel) applySettings(settings config.Settings) (RootModel, error) { m.settingsEditing = false m.settingsInput = strconv.Itoa(settings.SessionSize) m.settingsWriteDifficulty = config.NormalizeWriteModeDifficulty(settings.WriteModeDifficulty) + m.settingsUpdateCheckEnabled = settings.UpdateCheckEnabled m.settingsAudioEnabled = settings.AudioEnabled m.settingsAudioVoices, m.settingsAudioVoicesLoaded = m.loadAudioVoices() m.settingsAudioVoice = normalizeAudioVoiceInList(settings.AudioVoice, m.settingsAudioVoices, m.settingsAudioVoicesLoaded) diff --git a/internal/app/update.go b/internal/app/update.go index c9566b9..e14aca1 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -344,6 +344,8 @@ func (m RootModel) updateSettingsOverlay(msg tea.KeyPressMsg) (tea.Model, tea.Cm m.settingsInput = strconv.Itoa(count) case settingsRowWriteDifficulty: m.settingsWriteDifficulty = config.WriteModeDifficultyBasic + case settingsRowUpdateCheck: + m.settingsUpdateCheckEnabled = false case settingsRowAudioEnabled: m.settingsAudioEnabled = false m.settingsAudioAutoplay = false @@ -370,6 +372,8 @@ func (m RootModel) updateSettingsOverlay(msg tea.KeyPressMsg) (tea.Model, tea.Cm m.settingsInput = strconv.Itoa(count) case settingsRowWriteDifficulty: m.settingsWriteDifficulty = config.WriteModeDifficultyHard + case settingsRowUpdateCheck: + m.settingsUpdateCheckEnabled = true case settingsRowAudioEnabled: m.settingsAudioEnabled = true case settingsRowAudioVoice: diff --git a/internal/app/update_test.go b/internal/app/update_test.go index 93a98a4..cf7c9d2 100644 --- a/internal/app/update_test.go +++ b/internal/app/update_test.go @@ -950,6 +950,79 @@ func TestUpdateHomeSettingsDifficultySwitchesWithArrowKeys(t *testing.T) { } } +func TestUpdateHomeSettingsUpdateCheckSwitchesWithArrowKeys(t *testing.T) { + t.Parallel() + + model := NewModel(nil, Options{ + Settings: config.Settings{ + SessionSize: 10, + ReviewRatio: 0.4, + WriteModeDifficulty: config.WriteModeDifficultyBasic, + UpdateCheckEnabled: true, + AudioEnabled: true, + Language: i18n.LangJA, + ThemeMode: config.ThemeModeDefault, + }, + }) + model.loading = false + model = model.openSettingsOverlay() + model.settingsCursor = settingsRowUpdateCheck + + next, _ := model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + updated := next.(RootModel) + if updated.settingsUpdateCheckEnabled { + t.Fatal("settingsUpdateCheckEnabled after left = true, want false") + } + + next, _ = updated.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + updated = next.(RootModel) + if !updated.settingsUpdateCheckEnabled { + t.Fatal("settingsUpdateCheckEnabled after right = false, want true") + } +} + +func TestUpdateHomeSettingsSavePersistsUpdateCheckToggle(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + model := NewModel(nil, Options{ + Settings: config.Settings{ + SessionSize: 10, + ReviewRatio: 0.4, + WriteModeDifficulty: config.WriteModeDifficultyBasic, + UpdateCheckEnabled: true, + AudioEnabled: true, + Language: i18n.LangJA, + ThemeMode: config.ThemeModeDefault, + }, + ConfigPath: path, + }) + model.loading = false + model = model.openSettingsOverlay() + model.settingsCursor = settingsRowUpdateCheck + model.settingsUpdateCheckEnabled = false + + next, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + updated := next.(RootModel) + if cmd == nil { + t.Fatal("cmd = nil, want save settings command") + } + + saved, _ := updated.Update(cmd()) + final := saved.(RootModel) + if final.settings.UpdateCheckEnabled { + t.Fatal("settings.UpdateCheckEnabled = true, want false") + } + + savedSettings, err := config.Load(path) + if err != nil { + t.Fatalf("Load(saved config) error = %v", err) + } + if savedSettings.UpdateCheckEnabled { + t.Fatal("saved UpdateCheckEnabled = true, want false") + } +} + func TestUpdateKeymapEditorSavesOverrideAndAppliesImmediately(t *testing.T) { t.Parallel() diff --git a/internal/app/view.go b/internal/app/view.go index 60a7c68..438b67d 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -196,6 +196,7 @@ func (m RootModel) renderSettingsOverlay() string { "", m.renderSettingsRow(settingsRowQuestionCount, i18n.T(i18n.SettingsQuestions), m.settingsQuestionDisplay()), m.renderSettingsRow(settingsRowWriteDifficulty, i18n.T(i18n.SettingsWriteDifficulty), m.settingsWriteDifficultyLabel()), + m.renderSettingsRow(settingsRowUpdateCheck, i18n.T(i18n.SettingsUpdateCheck), audioStateLabel(m.settingsUpdateCheckEnabled)), m.renderSettingsRow(settingsRowAudioEnabled, i18n.T(i18n.SettingsAudioEnabled), audioStateLabel(m.settingsAudioEnabled)), m.renderSettingsRow(settingsRowAudioVoice, i18n.T(i18n.SettingsAudioVoice), m.settingsAudioVoiceLabel()), m.renderSettingsRow(settingsRowAudioAutoplay, i18n.T(i18n.SettingsAudioAutoplay), audioStateLabel(m.settingsAudioAutoplay && m.settingsAudioAvailable())), @@ -314,6 +315,7 @@ func (m RootModel) renderSettingsOverlayCompact() string { "", m.renderCompactSelectable(style, m.settingsCursor == settingsRowQuestionCount, i18n.T(i18n.SettingsQuestions), m.settingsQuestionDisplay()), m.renderCompactSelectable(style, m.settingsCursor == settingsRowWriteDifficulty, i18n.T(i18n.SettingsWriteDifficulty), m.settingsWriteDifficultyLabel()), + m.renderCompactSelectable(style, m.settingsCursor == settingsRowUpdateCheck, i18n.T(i18n.SettingsUpdateCheck), audioStateLabel(m.settingsUpdateCheckEnabled)), m.renderCompactSelectable(style, m.settingsCursor == settingsRowAudioEnabled, i18n.T(i18n.SettingsAudioEnabled), audioStateLabel(m.settingsAudioEnabled)), m.renderCompactSelectable(style, m.settingsCursor == settingsRowAudioVoice, i18n.T(i18n.SettingsAudioVoice), m.settingsAudioVoiceLabel()), m.renderCompactSelectable(style, m.settingsCursor == settingsRowAudioAutoplay, i18n.T(i18n.SettingsAudioAutoplay), audioStateLabel(m.settingsAudioAutoplay && m.settingsAudioAvailable())), diff --git a/internal/app/view_test.go b/internal/app/view_test.go index d8902b2..f925f65 100644 --- a/internal/app/view_test.go +++ b/internal/app/view_test.go @@ -791,6 +791,7 @@ func TestRenderHomeWithSettingsOverlayUsesScreenSwitch(t *testing.T) { model.settingsOpen = true model.settingsInput = "10" model.settingsWriteDifficulty = config.WriteModeDifficultyHard + model.settingsUpdateCheckEnabled = true model.settingsAudioEnabled = true model.settingsAudioAutoplay = true model.settingsLanguage = i18n.LangJA @@ -803,6 +804,7 @@ func TestRenderHomeWithSettingsOverlayUsesScreenSwitch(t *testing.T) { for _, want := range []string{ i18n.T(i18n.SettingsWriteDifficulty), i18n.T(i18n.SettingsWriteDifficultyHard), + i18n.T(i18n.SettingsUpdateCheck), i18n.T(i18n.SettingsAudioEnabled), i18n.T(i18n.SettingsAudioAutoplay), i18n.T(i18n.SettingsTheme), diff --git a/internal/config/config.go b/internal/config/config.go index da23b81..c1e550e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,7 @@ type Settings struct { ReviewRatio float64 FocusModeDefault bool WriteModeDifficulty string + UpdateCheckEnabled bool AudioEnabled bool AudioAutoplay bool AudioVoice string @@ -64,6 +65,7 @@ type fileSettings struct { ReviewRatio *float64 `toml:"review_ratio"` FocusModeDefault *bool `toml:"focus_mode_default"` WriteModeDifficulty *string `toml:"write_mode_difficulty"` + UpdateCheckEnabled *bool `toml:"startup_update_check"` AudioEnabled *bool `toml:"audio_enabled"` AudioAutoplay *bool `toml:"audio_autoplay"` AudioVoice *string `toml:"audio_voice"` @@ -86,6 +88,7 @@ func DefaultSettings() Settings { SessionSize: session.DefaultQuestionCount, ReviewRatio: session.DefaultReviewRatio, WriteModeDifficulty: WriteModeDifficultyBasic, + UpdateCheckEnabled: true, AudioEnabled: true, AudioAutoplay: false, Language: i18n.DefaultLang, @@ -98,6 +101,7 @@ func (s Settings) IsZero() bool { s.ReviewRatio == 0 && !s.FocusModeDefault && s.WriteModeDifficulty == "" && + !s.UpdateCheckEnabled && !s.AudioEnabled && !s.AudioAutoplay && s.AudioVoice == "" && @@ -143,6 +147,9 @@ func Load(path string) (Settings, error) { } settings.WriteModeDifficulty = writeModeDifficulty } + if raw.UpdateCheckEnabled != nil { + settings.UpdateCheckEnabled = *raw.UpdateCheckEnabled + } if raw.AudioEnabled != nil { settings.AudioEnabled = *raw.AudioEnabled } @@ -216,6 +223,7 @@ func Save(path string, settings Settings) error { ReviewRatio float64 `toml:"review_ratio"` FocusModeDefault bool `toml:"focus_mode_default"` WriteModeDifficulty string `toml:"write_mode_difficulty"` + UpdateCheckEnabled bool `toml:"startup_update_check"` AudioEnabled bool `toml:"audio_enabled"` AudioAutoplay bool `toml:"audio_autoplay"` AudioVoice string `toml:"audio_voice"` @@ -228,6 +236,7 @@ func Save(path string, settings Settings) error { ReviewRatio: settings.ReviewRatio, FocusModeDefault: settings.FocusModeDefault, WriteModeDifficulty: settings.WriteModeDifficulty, + UpdateCheckEnabled: settings.UpdateCheckEnabled, AudioEnabled: settings.AudioEnabled, AudioAutoplay: settings.AudioAutoplay, AudioVoice: settings.AudioVoice, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e90cc48..902a321 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -30,6 +30,7 @@ session_size = 12 review_ratio = 0.4 focus_mode_default = true write_mode_difficulty = "hard" +startup_update_check = false audio_enabled = false audio_autoplay = true audio_voice = " Samantha " @@ -53,6 +54,9 @@ audio_voice = " Samantha " if settings.WriteModeDifficulty != WriteModeDifficultyHard { t.Fatalf("WriteModeDifficulty = %q, want %q", settings.WriteModeDifficulty, WriteModeDifficultyHard) } + if settings.UpdateCheckEnabled { + t.Fatal("UpdateCheckEnabled = true, want false") + } if settings.AudioEnabled { t.Fatal("AudioEnabled = true, want false") } @@ -135,6 +139,7 @@ func TestSaveRoundTripsSettings(t *testing.T) { ReviewRatio: 0.6, FocusModeDefault: true, WriteModeDifficulty: WriteModeDifficultyHard, + UpdateCheckEnabled: false, AudioEnabled: false, AudioAutoplay: true, AudioVoice: "Samantha", diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go index ebfbcc8..0105011 100644 --- a/internal/i18n/i18n_test.go +++ b/internal/i18n/i18n_test.go @@ -98,6 +98,7 @@ func TestAllJAKeysExistInEN(t *testing.T) { i18n.HomeUpdateDetail, i18n.HomeUpdateHint, i18n.HomeKeys, i18n.SettingsTitle, i18n.SettingsQuestions, i18n.SettingsWriteDifficulty, i18n.SettingsWriteDifficultyBasic, i18n.SettingsWriteDifficultyHard, + i18n.SettingsUpdateCheck, i18n.SettingsAudioEnabled, i18n.SettingsAudioVoice, i18n.SettingsAudioVoiceAuto, i18n.SettingsAudioVoiceUnavailable, i18n.SettingsAudioAutoplay, i18n.SettingsLanguage, i18n.SettingsLanguageJA, i18n.SettingsLanguageEN, i18n.SettingsTheme, i18n.SettingsThemeDefault, i18n.SettingsThemeNoColor, diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index a08ca3e..cd4780f 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -34,6 +34,7 @@ const ( SettingsWriteDifficulty = "settings.write_difficulty" SettingsWriteDifficultyBasic = "settings.write_difficulty_basic" SettingsWriteDifficultyHard = "settings.write_difficulty_hard" + SettingsUpdateCheck = "settings.update_check" SettingsAudioEnabled = "settings.audio_enabled" SettingsAudioVoice = "settings.audio_voice" SettingsAudioVoiceAuto = "settings.audio_voice_auto"