Skip to content

Commit ecc7fbd

Browse files
committed
feat(nimiday): refine care workflows and Windows app behavior
1 parent 3619e56 commit ecc7fbd

23 files changed

Lines changed: 550 additions & 355 deletions

‎apps/nimiday/src-electron/main.ts‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import path from 'node:path';
22
import { fileURLToPath, pathToFileURL } from 'node:url';
3-
import { app, BrowserWindow, ipcMain, Menu, protocol, screen, session, webContents } from 'electron';
3+
import { app, BrowserWindow, ipcMain, Menu, nativeImage, protocol, screen, session, Tray, webContents } from 'electron';
44
import { configureNimiElectronAppHostProfile } from '@nimiplatform/kit/shell/electron/host-profile';
5+
import { registerWindowsNotificationIdentity } from './windows-notification-identity.js';
56

67
declare const __NIMI_ELECTRON_PRODUCTION__: boolean;
78

@@ -31,19 +32,33 @@ const APP_ID = 'nimi.day';
3132
const NATIVE_BUNDLE_IDENTIFIER = "ai.nimi.apps.nimi.day";
3233
const currentDir = path.dirname(fileURLToPath(import.meta.url));
3334
const appRoot = path.resolve(currentDir, '..');
35+
const appIconPath = path.join(appRoot, 'assets', 'app-icon.png');
3436
const preloadPath = path.join(currentDir, 'preload.cjs');
3537
const productionRendererUrl = pathToFileURL(path.join(appRoot, 'dist', 'index.html')).toString();
3638
const developmentRendererUrl = readDevelopmentRendererUrl();
3739
const rendererUrl = developmentRendererUrl || productionRendererUrl;
3840
const allowedRendererUrls = [rendererUrl];
3941
let resettingRenderer = false;
4042
let quitting = false;
43+
let tray: Tray | undefined;
4144
let followups: ReturnType<typeof import('./followup-host.js')['createDayFollowUpHost']> | undefined;
4245

4346
app.setName("NimiDay");
4447
app.setAppUserModelId(NATIVE_BUNDLE_IDENTIFIER);
48+
// Complete Windows sender registration before any renderer can notify.
49+
try {
50+
await registerWindowsNotificationIdentity({
51+
appUserModelId: NATIVE_BUNDLE_IDENTIFIER,
52+
displayName: app.getName(),
53+
iconPath: appIconPath,
54+
});
55+
} catch (error) {
56+
process.stderr.write(`[nimiday-notification-identity] ${error instanceof Error ? error.message : String(error)}\n`);
57+
app.exit(1);
58+
throw error;
59+
}
4560
Menu.setApplicationMenu(Menu.buildFromTemplate([
46-
{ label: 'NimiDay', submenu: [{ label: '打开 Day', click: () => { const window = BrowserWindow.getAllWindows()[0]; if (window) { window.show(); window.focus(); } else void createMainWindow(); } }, { label: '退出并停止 Day', role: 'quit' }] },
61+
{ label: 'NimiDay', submenu: [{ label: '打开 NimiDay', click: openMainWindow }, { label: '退出并停止 NimiDay', role: 'quit' }] },
4762
...(process.platform === 'darwin' ? [
4863
{ role: 'fileMenu' as const },
4964
{ role: 'editMenu' as const },
@@ -54,7 +69,20 @@ registerNimiElectronAppAssetProtocolScheme(protocol);
5469

5570
void app.whenReady().then(async () => {
5671
const { createDayFollowUpHost } = await import('./followup-host.js');
57-
followups = createDayFollowUpHost(() => { const window = BrowserWindow.getAllWindows()[0]; if (window) { window.show(); window.focus(); } else void createMainWindow(); });
72+
followups = createDayFollowUpHost(openMainWindow);
73+
// @nimi-authority: rule.nimi.nimiday.assistant.background-follow-up
74+
// Windows has no Dock/application menu once the last window closes.
75+
// Keep a user-owned way to reopen the live Host without stopping its work.
76+
if (process.platform === 'win32') {
77+
tray = new Tray(nativeImage.createFromPath(appIconPath).resize({ width: 16, height: 16 }));
78+
tray.setToolTip('NimiDay');
79+
tray.setContextMenu(Menu.buildFromTemplate([
80+
{ label: '打开 NimiDay', click: openMainWindow },
81+
{ type: 'separator' },
82+
{ label: '退出并停止 NimiDay', click: () => app.quit() },
83+
]));
84+
tray.on('click', openMainWindow);
85+
}
5886
registerNimiElectronAppBridge({
5987
appId: APP_ID,
6088
allowedRendererUrls,
@@ -65,14 +93,26 @@ void app.whenReady().then(async () => {
6593
onSessionInvalidated: () => { followups?.invalidate(); resetAccountScopedRenderer(); },
6694
});
6795
await createMainWindow();
68-
app.on('activate', () => {
69-
if (BrowserWindow.getAllWindows().length === 0) void createMainWindow();
70-
});
96+
app.on('activate', openMainWindow);
7197
});
7298

7399
// Accepted arrangements keep following real replies in this protected Host.
74100
app.on('window-all-closed', () => {});
75-
app.on('before-quit', () => { quitting = true; followups?.invalidate(); });
101+
app.on('before-quit', () => { quitting = true; tray?.destroy(); tray = undefined; followups?.invalidate(); });
102+
103+
function openMainWindow(): void {
104+
if (quitting) return;
105+
const window = BrowserWindow.getAllWindows()[0];
106+
if (window) {
107+
if (window.isMinimized()) window.restore();
108+
window.show();
109+
window.focus();
110+
} else {
111+
void createMainWindow().catch((error: unknown) => {
112+
process.stderr.write(`[nimiday-open-window] ${error instanceof Error ? error.message : String(error)}\n`);
113+
});
114+
}
115+
}
76116

77117
// A revoked/account-changed scope must not leave renderer timers or queued
78118
// promises alive to use the rebound Host. This does not run on normal renewal.
@@ -97,6 +137,7 @@ async function createMainWindow(): Promise<void> {
97137
minHeight: Math.min(560, height),
98138
center: true,
99139
title: "NimiDay",
140+
icon: appIconPath,
100141
autoHideMenuBar: true,
101142
webPreferences: {
102143
preload: preloadPath,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { execFile } from 'node:child_process';
2+
import { access } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { promisify } from 'node:util';
5+
6+
const runFile = promisify(execFile);
7+
8+
/** Register the notification sender, not a launcher for this supervised App. */
9+
export async function registerWindowsNotificationIdentity(identity: {
10+
appUserModelId: string;
11+
displayName: string;
12+
iconPath: string;
13+
}): Promise<void> {
14+
if (process.platform !== 'win32') return;
15+
const systemRoot = process.env.SystemRoot;
16+
if (!systemRoot) throw new Error('Windows SystemRoot is unavailable.');
17+
await access(identity.iconPath);
18+
19+
// app.setName() does not change the executable product name used by
20+
// Electron's automatic Windows shortcut. Register the explicit AUMID's
21+
// display assets so shared electron.exe hosts retain their own identity.
22+
// https://learn.microsoft.com/windows/apps/design/shell/tiles-and-notifications/send-local-toast-other-apps
23+
const key = `HKCU\\Software\\Classes\\AppUserModelId\\${identity.appUserModelId}`;
24+
const registry = path.join(systemRoot, 'System32', 'reg.exe');
25+
// Windows defines both display asset values as REG_EXPAND_SZ, not REG_SZ.
26+
for (const [name, value] of [
27+
['DisplayName', identity.displayName],
28+
['IconUri', identity.iconPath],
29+
] as const) {
30+
await runFile(registry, ['add', key, '/v', name, '/t', 'REG_EXPAND_SZ', '/d', value, '/f'], {
31+
windowsHide: true,
32+
timeout: 10_000,
33+
});
34+
}
35+
}

‎apps/nimiday/src/nimiday/domain/deliveries.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@ export type ReminderPlan = {
1616
readonly missed: readonly { readonly item: LifeItem; readonly at: string }[];
1717
};
1818

19-
/** Late by more than this when first seen means NimiDay was not running. */
20-
export const MISSED_THRESHOLD_MS = 2 * 60_000;
21-
2219
export function planReminders(
2320
items: readonly LifeItem[],
2421
now: Date,
@@ -32,7 +29,7 @@ export function planReminders(
3229
if (!isReminderPending(item, now, settings)) continue;
3330
const instant = reminderInstant(item, settings)!;
3431
const at = instant.toISOString();
35-
if (instant.getTime() < sessionStartedAt.getTime() - MISSED_THRESHOLD_MS) {
32+
if (instant.getTime() < sessionStartedAt.getTime()) {
3633
missed.push({ item, at });
3734
continue;
3835
}

‎apps/nimiday/src/nimiday/i18n/en.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export const en: Copy = {
100100
connectionAffected: (count: number) => `Connection or assistant unavailable for ${count} unfinished follow-up(s)`,
101101
connectionAffectedHint: 'Restore the original connections or assistant access, then check each notice and reply status. Restoring setup does not automatically resend notices.',
102102
refreshConnections: 'Refresh connections', setupNeeded: 'Choose an available assistant and messaging account first.', unavailable: 'Unavailable',
103-
backToList: 'Back to follow-ups', draftHint: 'Going back to the list keeps your draft, but leaving Follow-ups clears it.',
103+
backToList: 'Back to follow-ups', draftHint: 'Your draft stays when switching pages. Reopening or refreshing NimiDay clears it.', resumeDraft: 'Continue your arrangement',
104104
contentTitle: 'What are you arranging?', contentHint: 'Tell your assistant when, where, and what a successful result looks like.',
105105
deliveryTitle: 'Who should be notified, and what should it say?', deliveryHint: 'Supports Telegram private chats using verified numeric chat IDs.',
106106
goalPrivate: 'Instructions for your assistant; this is not sent as the notice.',
@@ -398,6 +398,11 @@ export const en: Copy = {
398398
},
399399
items: {
400400
title: 'Items',
401+
search: 'Search all items: title, notes, place or person',
402+
clearSearch: 'Clear search',
403+
searchCount: (count: number) => `${count} results · Includes completed and cancelled items`,
404+
noMatches: 'No matching items. Try another keyword or person.',
405+
moreHistory: 'Show more completed records',
401406
upcoming: 'Upcoming',
402407
waiting: 'Waiting for you',
403408
recurring: 'Repeating',
@@ -413,6 +418,26 @@ export const en: Copy = {
413418
nothingThatDay: '—',
414419
},
415420
care: {
421+
workspaceHint: 'Keep close to your people and what matters to them.',
422+
findNote: 'Search titles, details or people',
423+
noMatchingNotes: 'No matching notes',
424+
moreNotes: (count: number) => `Show more (${count} remaining)`,
425+
people: 'Care circles',
426+
everyone: 'All care',
427+
everyoneHint: 'Family, yourself and the things you look after.',
428+
findPerson: 'Find a care circle',
429+
noMatches: 'No matching care circles',
430+
contentLabel: 'Care content',
431+
updates: 'Updates',
432+
updatesHint: 'Reminders and records from other apps, open reminders first.',
433+
allItemsHint: 'Care arrangements, ordered by date. Choose a person to add an item for them.',
434+
itemsHint: 'Your NimiDay arrangements, ordered by date.',
435+
noChangesHint: 'Find your arrangements and useful notes in the other tabs.',
436+
onlyUnread: 'Unread only',
437+
noUnread: 'You’re all caught up',
438+
loadMoreUpdates: (count: number) => `Show ${count} more`,
439+
unassigned: 'Not yet assigned to a care circle',
440+
unassignedHint: 'These updates have no confirmed owner. Review them before assigning them.',
416441
title: 'Care',
417442
subtitle: 'Focus, arrangements and changes for the people and areas you care about',
418443
watchLabel: 'Focus',
@@ -447,6 +472,7 @@ export const en: Copy = {
447472
watchPlaceholder: 'e.g. shots and checkups on time; not too late to bed; mind the allergy',
448473
focus: 'Focus',
449474
focusPlaceholder: 'Comma separated, e.g. shots, sleep',
475+
optionalDetails: 'Add care details (optional)',
450476
name: 'Name',
451477
kind: 'Type',
452478
openItems: (count: number) => `Open items · ${count}`,
@@ -546,6 +572,7 @@ export const en: Copy = {
546572
rhythmSkill: 'Skill',
547573
rhythmDays: 'Days',
548574
rhythmTime: 'Time',
575+
chooseWeekday: 'Choose at least one day.',
549576
daysOptions: { daily: 'Every day', weekdays: 'Weekdays', weekends: 'Weekends', custom: 'Choose' },
550577
deleteRhythm: 'Delete routine',
551578
runsEmpty: 'No runs yet',
@@ -872,7 +899,7 @@ export const en: Copy = {
872899
language: 'Language',
873900
languages: { auto: 'Match system', zh: '中文', en: 'English' },
874901
about: 'About',
875-
aboutRuntime: 'Quitting NimiDay pauses reminders and routines. When you return, missed reminders appear under “While you were away”; missed routines do not start automatically.',
902+
aboutRuntime: 'Quitting NimiDay pauses reminders and routines. When you return, missed reminders appear under “While you were away”; missed routines do not start automatically. On Windows, closing the window leaves NimiDay available in the taskbar notification area. Choose “Quit and stop NimiDay” to stop reminders.',
876903
aboutData: 'Your records are kept on this computer, separately for each account. Nimi keeps your assistant’s conversations and memories; NimiDay does not keep another copy.',
877904
sourceMeta: (count: number, when: string) => `${count} ${count === 1 ? 'update' : 'updates'} · latest ${when}`,
878905
version: (value: string) => `Version ${value}`,

‎apps/nimiday/src/nimiday/i18n/zh.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const zh = {
9898
connectionAffected: (count: number) => `${count} 项未结束安排的连接或助理不可用`,
9999
connectionAffectedHint: '请恢复这些安排原先使用的连接或助理权限,并查看各项通知和回复的实际状态。恢复设置不会自动重发通知。',
100100
refreshConnections: '刷新连接', setupNeeded: '请先选择可用的助理和消息账户。', unavailable: '暂不可用',
101-
backToList: '返回跟进列表', draftHint: '返回跟进列表会保留草稿,切换到其他页面后将不再保留。',
101+
backToList: '返回跟进列表', draftHint: '切换页面会保留草稿;重新打开或刷新 NimiDay 后需重新填写。', resumeDraft: '继续填写安排',
102102
contentTitle: '这次要安排什么?', contentHint: '告诉助理时间、地点,以及什么结果才算办好了。',
103103
deliveryTitle: '发给谁,怎么说?', deliveryHint: '目前仅支持 Telegram 私聊,请使用已核对的数字聊天 ID。',
104104
goalPrivate: '这是交给助理的要求,不会作为通知正文发送。',
@@ -396,6 +396,11 @@ export const zh = {
396396
},
397397
items: {
398398
title: '事项',
399+
search: '搜索全部事项:标题、备注、地点或对象',
400+
clearSearch: '清除搜索',
401+
searchCount: (count: number) => `找到 ${count} 项 · 包含已完成和已取消的事项`,
402+
noMatches: '没有找到匹配的事项,可以换个关键词或照看对象。',
403+
moreHistory: '查看更多完成记录',
399404
upcoming: '接下来',
400405
waiting: '等你决定',
401406
recurring: '重复',
@@ -411,6 +416,26 @@ export const zh = {
411416
nothingThatDay: '—',
412417
},
413418
care: {
419+
workspaceHint: '照看重要的人,记住需要上心的事。',
420+
findNote: '查找标题、内容或对象',
421+
noMatchingNotes: '没有找到相关条目',
422+
moreNotes: (count: number) => `继续查看(还有 ${count} 条)`,
423+
people: '照看对象',
424+
everyone: '全部照看',
425+
everyoneHint: '家人、自己与生活里的事,都在这里。',
426+
findPerson: '查找照看对象',
427+
noMatches: '没有找到这个对象',
428+
contentLabel: '照看内容',
429+
updates: '动态',
430+
updatesHint: '来自其他 App 的提醒与记录,待办提醒优先。',
431+
allItemsHint: '按日期查看照看事项;选择一个对象后,可以为其添加事项。',
432+
itemsHint: '在 NimiDay 安排的事项,按日期排列。',
433+
noChangesHint: '已安排的事项和常用资料,可以在旁边的栏目查看。',
434+
onlyUnread: '只看未读',
435+
noUnread: '未读动态已看完',
436+
loadMoreUpdates: (count: number) => `再看 ${count} 条`,
437+
unassigned: '尚未归入照看对象',
438+
unassignedHint: '这些动态还没有明确归属,确认后再放到相应对象下。',
414439
title: '照看',
415440
subtitle: '按人和生活领域,查看关注重点、事项与相关变化',
416441
watchLabel: '关注重点',
@@ -445,6 +470,7 @@ export const zh = {
445470
watchPlaceholder: '例如:疫苗和体检按时做;睡眠别太晚;过敏要注意',
446471
focus: '关注点',
447472
focusPlaceholder: '用逗号分开,例如:疫苗, 睡眠',
473+
optionalDetails: '补充照看重点(选填)',
448474
name: '名字',
449475
kind: '类型',
450476
openItems: (count: number) => `未完成事项 · ${count}`,
@@ -544,6 +570,7 @@ export const zh = {
544570
rhythmSkill: '使用技能',
545571
rhythmDays: '哪些天',
546572
rhythmTime: '时间',
573+
chooseWeekday: '请至少选择一天。',
547574
daysOptions: { daily: '每天', weekdays: '工作日', weekends: '周末', custom: '自选' },
548575
deleteRhythm: '删除例行',
549576
runsEmpty: '还没有运行记录',
@@ -870,7 +897,7 @@ export const zh = {
870897
language: '语言',
871898
languages: { auto: '跟随系统', zh: '中文', en: 'English' },
872899
about: '关于',
873-
aboutRuntime: "退出 NimiDay 后,提醒和例行会暂停。再次打开时,错过的提醒会汇总在「你不在时」,错过的例行不会自动开始。",
900+
aboutRuntime: "退出 NimiDay 后,提醒和例行会暂停。再次打开时,错过的提醒会汇总在「你不在时」,错过的例行不会自动开始。在 Windows 上,关闭窗口后仍可从任务栏通知区域打开 NimiDay;选择「退出并停止 NimiDay」才会停止提醒。",
874901
aboutData: '你的资料保存在这台电脑上,不同账号各自保存。助理的对话和记忆由 Nimi 保存,NimiDay 不会另存一份。',
875902
sourceMeta: (count: number, when: string) => `${count} 条动态 · 最近 ${when}`,
876903
version: (value: string) => `版本 ${value}`,

‎apps/nimiday/src/nimiday/platform/notifier.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// decided by the caller; this module only delivers.
44

55
import { nimiToast } from '@nimiplatform/kit/ui';
6+
import notificationIcon from '../../../assets/app-icon.png';
67

78
export type SystemPermission = 'granted' | 'denied' | 'default' | 'unsupported';
89
export type SystemDeliveryStatus = 'submitted' | 'shown' | 'failed' | 'unavailable' | 'unconfirmed';
@@ -51,7 +52,7 @@ export function deliver(delivery: Delivery): { readonly system: boolean } {
5152
}
5253
try {
5354
shown.get(delivery.tag)?.close();
54-
const notification = new Notification(delivery.title, { body: delivery.body, tag: delivery.tag });
55+
const notification = new Notification(delivery.title, { body: delivery.body, tag: delivery.tag, icon: notificationIcon });
5556
// Windows can suppress a notification without changing the browser's
5657
// permission or emitting show/error. Do not leave a test waiting forever.
5758
const feedbackTimer = delivery.onSystemStatus

0 commit comments

Comments
 (0)