Skip to content

Commit c162e43

Browse files
committed
Add pause button
1 parent bcba4a7 commit c162e43

10 files changed

Lines changed: 372 additions & 109 deletions

File tree

.github/workflows/build.yml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ jobs:
1717
steps:
1818
- uses: actions/checkout@v4
1919

20+
# ── On a tag push, drop a placeholder release immediately so the Releases
21+
# page shows "🚧 Building…" while CI is still running. We update it with
22+
# the real assets at the end. If anything fails, the draft remains visible
23+
# for the maintainer to delete or retry.
24+
- name: Create placeholder release
25+
if: startsWith(github.ref, 'refs/tags/v')
26+
uses: softprops/action-gh-release@v2
27+
with:
28+
tag_name: ${{ github.ref_name }}
29+
name: "🚧 ${{ github.ref_name }} — Building…"
30+
body: |
31+
Build is in progress. The portable `.exe` and `Setup.exe` will appear here once CI completes (usually a few minutes).
32+
See the [Actions tab](../../actions) for live progress.
33+
draft: true
34+
prerelease: false
35+
2036
- name: Set up .NET 8
2137
uses: actions/setup-dotnet@v4
2238
with:
@@ -51,13 +67,20 @@ jobs:
5167
path: installer/Output/Twenti-Setup.exe
5268
if-no-files-found: error
5369

54-
- name: Create GitHub Release on tag
70+
# ── Final step on tag push: replace the "Building…" placeholder with the
71+
# real release containing both the portable .exe and the Setup .exe as
72+
# raw downloadable files (NOT zipped — the workflow-artifact zips above
73+
# are just for non-tag CI runs).
74+
- name: Publish release
5575
if: startsWith(github.ref, 'refs/tags/v')
5676
uses: softprops/action-gh-release@v2
5777
with:
78+
tag_name: ${{ github.ref_name }}
79+
name: ${{ github.ref_name }}
5880
files: |
5981
publish/Twenti.exe
6082
installer/Output/Twenti-Setup.exe
83+
draft: false
6184
generate_release_notes: true
6285
env:
6386
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

App.xaml.cs

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Diagnostics;
3+
using System.Threading.Tasks;
24
using H.NotifyIcon;
35
using Microsoft.UI.Dispatching;
46
using Microsoft.UI.Xaml;
@@ -15,6 +17,7 @@ public partial class App : Application
1517
public BreakStateMachine StateMachine { get; private set; } = null!;
1618
public SoundEngine Sound { get; private set; } = null!;
1719
public ThemeListener Theme { get; private set; } = null!;
20+
public AppSettings Settings { get; private set; } = null!;
1821
public DispatcherQueue UIQueue { get; private set; } = null!;
1922

2023
private TaskbarIcon? _trayIcon;
@@ -34,7 +37,8 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
3437

3538
_ownerWindow = new MainWindow();
3639

37-
Sound = new SoundEngine();
40+
Settings = AppSettings.Load();
41+
Sound = new SoundEngine { Muted = Settings.Muted };
3842
Theme = new ThemeListener();
3943
StateMachine = new BreakStateMachine(UIQueue);
4044

@@ -56,6 +60,48 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
5660

5761
StateMachine.Start();
5862
RefreshTray();
63+
64+
if (Settings.CheckForUpdates)
65+
{
66+
_ = Task.Delay(TimeSpan.FromSeconds(5)).ContinueWith(_ => CheckForUpdatesAsync(silentIfNone: true));
67+
}
68+
}
69+
70+
private async Task CheckForUpdatesAsync(bool silentIfNone)
71+
{
72+
var info = await new UpdateChecker().CheckAsync().ConfigureAwait(false);
73+
UIQueue.TryEnqueue(() =>
74+
{
75+
if (info is null)
76+
{
77+
if (!silentIfNone) ShowToast("You're on the latest version.");
78+
return;
79+
}
80+
81+
if (_trayIcon is null) return;
82+
_trayIcon.ShowNotification(
83+
title: $"Twenti {info.LatestVersion} is available",
84+
message: "Click to open the release page.",
85+
timeout: TimeSpan.FromSeconds(10));
86+
87+
// The first left-click after the toast also takes them there.
88+
_trayIcon.LeftClickCommand = new RelayCommand(() =>
89+
{
90+
_trayIcon.LeftClickCommand = new RelayCommand(OnTrayLeftClick);
91+
OpenUrl(info.ReleaseUrl);
92+
});
93+
});
94+
}
95+
96+
private void ShowToast(string message)
97+
{
98+
_trayIcon?.ShowNotification("Twenti", message, timeout: TimeSpan.FromSeconds(4));
99+
}
100+
101+
private static void OpenUrl(string url)
102+
{
103+
try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
104+
catch { /* ignore */ }
59105
}
60106

61107
private MenuFlyout BuildContextMenu()
@@ -71,9 +117,30 @@ private MenuFlyout BuildContextMenu()
71117
menu.Items.Add(new MenuFlyoutSeparator());
72118

73119
var muteItem = new ToggleMenuFlyoutItem { Text = "Mute sounds", IsChecked = Sound.Muted };
74-
muteItem.Click += (_, _) => Sound.Muted = muteItem.IsChecked;
120+
muteItem.Click += (_, _) =>
121+
{
122+
Sound.Muted = muteItem.IsChecked;
123+
Settings.Muted = muteItem.IsChecked;
124+
Settings.Save();
125+
};
75126
menu.Items.Add(muteItem);
76127

128+
var updateToggle = new ToggleMenuFlyoutItem
129+
{
130+
Text = "Check for updates",
131+
IsChecked = Settings.CheckForUpdates,
132+
};
133+
updateToggle.Click += (_, _) =>
134+
{
135+
Settings.CheckForUpdates = updateToggle.IsChecked;
136+
Settings.Save();
137+
};
138+
menu.Items.Add(updateToggle);
139+
140+
var checkNow = new MenuFlyoutItem { Text = "Check for updates now" };
141+
checkNow.Click += async (_, _) => await CheckForUpdatesAsync(silentIfNone: false);
142+
menu.Items.Add(checkNow);
143+
77144
menu.Items.Add(new MenuFlyoutSeparator());
78145

79146
var quit = new MenuFlyoutItem

README.md

Lines changed: 19 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,40 @@
11
# Twenti — 20/20 Eye Break Reminder
22

3-
A Windows 11 native eye-break reminder. Lives in the system tray, counts down your work interval, surfaces a calm break prompt, and gets out of the way.
3+
[![Build](https://github.com/Sammeeeeeeee/Twenti/actions/workflows/build.yml/badge.svg)](https://github.com/Sammeeeeeeee/Twenti/actions/workflows/build.yml)
44

5-
Built with **WinUI 3** (Windows App SDK) — same stack as PowerToys and the Windows 11 Settings app, so it looks and feels truly native (Mica + Acrylic backdrops, official Fluent tokens, real WinUI menus).
5+
A Windows 11 native eye-break reminder.
66

7-
## Surfaces
7+
## What is the 20/20/20 rule?
88

9-
1. **Tray icon** — the only persistent UI. Shows minutes left in green, switches to yellow under 2 min, pulses yellow during the 5-second pre-ping, eye glyph during the break, grey while snoozed.
10-
2. **Tray flyout** (left-click) — acrylic flyout with the live countdown, "Start break now" button, and quick snooze options. Slides up from the tray on click and auto-opens during the pre-ping.
11-
3. **Break popup** (when the timer fires) — centered Mica card with two states: prompt (`[Start 20 sec]` / `[Snooze]`) and timer (big 56px countdown with progress bar).
9+
The [20/20/20 rule]() is a [proven tecnique](https://pubmed.ncbi.nlm.nih.gov/36473088/) to prevent eye strain, dryness and degradation.
1210

13-
The 3rd break in every cycle is a long 2-minute break instead of the usual 20 seconds.
11+
## Twenti
1412

15-
## Keyboard (while the popup is focused)
13+
Built with **WinUI 3** (Windows App SDK), this aims to be native and light, to be as unabtrusive as possbile, but easily acessible.
14+
It lives in the trey, with the minutes left as countdown. A flyut on click shows more information. Every 20 minutes, a pop up appearsin the centre of your screen. You can choose to delay, or press enter to start the countdown (optional: accmpanied by white nose). Every 3rd pop up is for 2 minutes.
15+
16+
### Keyboard (while the popup is focused)
1617

1718
- `Enter` — start the break timer
1819
- `Esc` — snooze 5 minutes
1920
- `1``9` — snooze N minutes
2021

21-
## Right-click on the tray icon
22+
### Right-click on the tray icon
2223

23-
Snooze 5 / 15 / 30 minutes · Mute or Unmute sounds · Quit
24+
Snooze 5 / 15 / 30 minutes · Mute or Unmute sounds
2425

25-
## Sounds (synthesised, no audio files)
26+
### Sounds (mutable)
2627

2728
- Soft 1318 Hz pre-ping 5 seconds before
2829
- Warm 3-note chime when the popup appears
2930
- Brown-noise water ambient during the break
3031
- Rising 3-note resolution when the break completes
3132
- Descending 2-note acknowledgement when snoozed
3233

33-
All produced live by NAudio — no `.wav` files shipped.
34+
## Pre-built downloads
35+
36+
- **Recommended → [Releases](../../releases)**.`Twenti.exe` (portable) and `Twenti-Setup.exe` (installer).
37+
- **Latest dev build** — pull from the most recent [Actions run](../../actions).
3438

3539
## Build & run from source
3640

@@ -42,15 +46,15 @@ dotnet build
4246
dotnet run
4347
```
4448

45-
The app starts straight into the system tray — no main window appears.
49+
The app starts straight into the system tray.
4650

4751
## Build a portable single-file exe
4852

4953
```pwsh
5054
dotnet publish -c Release -r win-x64 -o publish
5155
```
5256

53-
Output: `publish\Twenti.exe`. The Windows App SDK runtime is bundled — the exe runs on a clean Windows 11 box without any prerequisites.
57+
Output: `publish\Twenti.exe`. The Windows App SDK runtime is bundled.
5458

5559
## Build the installer
5660

@@ -59,37 +63,4 @@ dotnet publish -c Release -r win-x64 -o publish
5963
& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" installer\Twenti.iss
6064
```
6165

62-
Output: `installer\Output\Twenti-Setup.exe`. Installs into Program Files, adds a Start Menu entry, optionally runs at login.
63-
64-
## Pre-built downloads
65-
66-
Every push to `main` builds both the portable exe and the installer in CI.
67-
68-
- **Latest** — pull artifacts from the latest [Actions run](../../actions).
69-
- **Release** — push a `vX.Y.Z` tag, GitHub Actions automatically attaches both files to the new release.
70-
71-
## Project layout
72-
73-
```
74-
Twenti/
75-
├── .github/workflows/build.yml # CI: portable exe + Inno Setup installer
76-
├── installer/Twenti.iss # Inno Setup script
77-
├── Twenti.csproj
78-
├── app.manifest # PerMonitorV2 DPI awareness
79-
├── Program.cs # [STAThread] Main, single-instance mutex
80-
├── App.xaml(.cs) # Tray icon + service wiring
81-
├── MainWindow.xaml(.cs) # Hidden owner window, off-screen
82-
├── Views/
83-
│ ├── BreakPopup.xaml(.cs) # 380px Mica card popup
84-
│ ├── TrayFlyout.xaml(.cs) # 240px Acrylic flyout
85-
│ └── ThemedResources.xaml # Status colour brushes (light/dark)
86-
└── Services/
87-
├── BreakStateMachine.cs # Phase + tick + 3-cycle rhythm
88-
├── SoundEngine.cs # NAudio synthesis (5 cues + ambient)
89-
├── TrayIconRenderer.cs # Runtime ICO generation per state
90-
└── ThemeListener.cs # UISettings.ColorValuesChanged hook
91-
```
92-
93-
## License
94-
95-
[MIT](LICENSE)
66+
Output: `installer\Output\Twenti-Setup.exe`. Installs as user or system wide. Adds a Start Menu entry, optionally runs at login.

Services/AppSettings.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System;
2+
using System.IO;
3+
using System.Text.Json;
4+
5+
namespace Twenti.Services;
6+
7+
public sealed class AppSettings
8+
{
9+
public bool CheckForUpdates { get; set; } = true;
10+
public bool Muted { get; set; } = false;
11+
12+
private static readonly string SettingsPath = Path.Combine(
13+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
14+
"Twenti", "settings.json");
15+
16+
public static AppSettings Load()
17+
{
18+
try
19+
{
20+
if (File.Exists(SettingsPath))
21+
{
22+
var json = File.ReadAllText(SettingsPath);
23+
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
24+
}
25+
}
26+
catch
27+
{
28+
// ignore — fall through to defaults
29+
}
30+
return new AppSettings();
31+
}
32+
33+
public void Save()
34+
{
35+
try
36+
{
37+
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
38+
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
39+
File.WriteAllText(SettingsPath, json);
40+
}
41+
catch
42+
{
43+
// ignore — non-critical
44+
}
45+
}
46+
}

Services/UpdateChecker.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System;
2+
using System.Net.Http;
3+
using System.Reflection;
4+
using System.Text.Json;
5+
using System.Threading.Tasks;
6+
7+
namespace Twenti.Services;
8+
9+
public sealed class UpdateInfo
10+
{
11+
public required string LatestVersion { get; init; }
12+
public required string CurrentVersion { get; init; }
13+
public required string ReleaseUrl { get; init; }
14+
public required string ReleaseNotes { get; init; }
15+
}
16+
17+
public sealed class UpdateChecker
18+
{
19+
private const string ReleasesApi = "https://api.github.com/repos/Sammeeeeeeee/Twenti/releases/latest";
20+
21+
private static readonly HttpClient Http;
22+
23+
static UpdateChecker()
24+
{
25+
Http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
26+
Http.DefaultRequestHeaders.UserAgent.ParseAdd("Twenti-UpdateChecker/1.0");
27+
Http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
28+
}
29+
30+
public static string CurrentVersion =>
31+
Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3) ?? "0.0.0";
32+
33+
public async Task<UpdateInfo?> CheckAsync()
34+
{
35+
try
36+
{
37+
var json = await Http.GetStringAsync(ReleasesApi).ConfigureAwait(false);
38+
using var doc = JsonDocument.Parse(json);
39+
var root = doc.RootElement;
40+
41+
string tag = root.GetProperty("tag_name").GetString() ?? "";
42+
string url = root.GetProperty("html_url").GetString() ?? "";
43+
string body = root.TryGetProperty("body", out var bp) ? (bp.GetString() ?? "") : "";
44+
45+
string latest = tag.TrimStart('v', 'V').Trim();
46+
string current = CurrentVersion;
47+
48+
if (IsNewer(latest, current))
49+
{
50+
return new UpdateInfo
51+
{
52+
LatestVersion = latest,
53+
CurrentVersion = current,
54+
ReleaseUrl = url,
55+
ReleaseNotes = body,
56+
};
57+
}
58+
}
59+
catch
60+
{
61+
// Network error / repo missing / rate-limited — silent fail.
62+
}
63+
return null;
64+
}
65+
66+
private static bool IsNewer(string latest, string current)
67+
{
68+
if (Version.TryParse(Pad(latest), out var l) && Version.TryParse(Pad(current), out var c))
69+
{
70+
return l > c;
71+
}
72+
return !string.IsNullOrEmpty(latest) && !string.Equals(latest, current, StringComparison.OrdinalIgnoreCase);
73+
}
74+
75+
// Version.TryParse needs at least Major.Minor — pad single-component strings.
76+
private static string Pad(string v)
77+
{
78+
int dots = 0;
79+
foreach (var ch in v) if (ch == '.') dots++;
80+
return dots switch
81+
{
82+
0 => v + ".0.0",
83+
1 => v + ".0",
84+
_ => v,
85+
};
86+
}
87+
}

0 commit comments

Comments
 (0)