Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 |
22
package-lock.json
generated
22
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -3305,6 +3305,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.3",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
@@ -11955,7 +11964,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -11990,7 +11999,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12030,7 +12039,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12038,7 +12047,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
@@ -12048,6 +12057,7 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.10.1",
|
"minServerVersion": "0.10.2",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -91,4 +91,23 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
}
|
}
|
||||||
return { enabled: false }
|
return { enabled: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"notifications:show",
|
||||||
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
if (!Notification.isSupported()) {
|
||||||
|
return { ok: false, reason: "unsupported" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
|
||||||
|
const body = typeof payload?.body === "string" ? payload.body : ""
|
||||||
|
try {
|
||||||
|
const notification = new Notification({ title, body })
|
||||||
|
notification.show()
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -473,6 +473,14 @@ if (isMac) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
// Required for Windows notifications / taskbar grouping.
|
||||||
|
// Keep in sync with desktop app identifier.
|
||||||
|
try {
|
||||||
|
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
startCli()
|
startCli()
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
|
|||||||
@@ -381,6 +381,9 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
args.push("--https", "false", "--http", "true")
|
args.push("--https", "false", "--http", "true")
|
||||||
|
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
||||||
|
// by forcing an ephemeral port in dev.
|
||||||
|
args.push("--http-port", "0")
|
||||||
} else {
|
} else {
|
||||||
// Prod desktop: always keep loopback HTTP enabled.
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
args.push("--https", "true", "--http", "true")
|
args.push("--https", "true", "--http", "true")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const electronAPI = {
|
|||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import http from "http"
|
||||||
|
import https from "https"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
|
||||||
export type PluginEvent = {
|
export type PluginEvent = {
|
||||||
type: string
|
type: string
|
||||||
properties?: Record<string, unknown>
|
properties?: Record<string, unknown>
|
||||||
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||||
|
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||||
const authorization = buildInstanceAuthorizationHeader()
|
const authorization = buildInstanceAuthorizationHeader()
|
||||||
|
|
||||||
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
|||||||
const hasBody = init?.body !== undefined
|
const hasBody = init?.body !== undefined
|
||||||
const headers = buildHeaders(init?.headers, hasBody)
|
const headers = buildHeaders(init?.headers, hasBody)
|
||||||
|
|
||||||
return fetch(url, {
|
// The CodeNomad plugin only talks to the local CodeNomad server.
|
||||||
...init,
|
// Use a single request implementation that tolerates custom/self-signed certs
|
||||||
headers,
|
// without disabling TLS verification for the whole Node process.
|
||||||
})
|
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function nodeFetch(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit & { headers?: Record<string, string> },
|
||||||
|
tls: { rejectUnauthorized: boolean },
|
||||||
|
): Promise<Response> {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const isHttps = parsed.protocol === "https:"
|
||||||
|
const requestFn = isHttps ? https.request : http.request
|
||||||
|
|
||||||
|
const method = (init.method ?? "GET").toUpperCase()
|
||||||
|
const headers = init.headers ?? {}
|
||||||
|
const body = init.body
|
||||||
|
|
||||||
|
return await new Promise<Response>((resolve, reject) => {
|
||||||
|
const req = requestFn(
|
||||||
|
{
|
||||||
|
protocol: parsed.protocol,
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port ? Number(parsed.port) : undefined,
|
||||||
|
path: `${parsed.pathname}${parsed.search}`,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const responseHeaders = new Headers()
|
||||||
|
for (const [key, value] of Object.entries(res.headers)) {
|
||||||
|
if (value === undefined) continue
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
responseHeaders.set(key, value.join(", "))
|
||||||
|
} else {
|
||||||
|
responseHeaders.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Node stream -> Web ReadableStream for Response.
|
||||||
|
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
|
||||||
|
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const signal = init.signal
|
||||||
|
const abort = () => {
|
||||||
|
const err = new Error("Request aborted")
|
||||||
|
;(err as any).name = "AbortError"
|
||||||
|
req.destroy(err)
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", abort, { once: true })
|
||||||
|
req.once("close", () => signal.removeEventListener("abort", abort))
|
||||||
|
}
|
||||||
|
|
||||||
|
req.once("error", reject)
|
||||||
|
|
||||||
|
if (body === undefined || body === null) {
|
||||||
|
req.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === "string") {
|
||||||
|
req.end(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof Uint8Array) {
|
||||||
|
req.end(Buffer.from(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof ArrayBuffer) {
|
||||||
|
req.end(Buffer.from(new Uint8Array(body)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for less common BodyInit types.
|
||||||
|
req.end(String(body))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
function requireEnv(key: string): string {
|
||||||
const value = process.env[key]
|
const value = process.env[key]
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ const PreferencesSchema = z.object({
|
|||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
autoCleanupBlankSessions: z.boolean().default(true),
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||||
|
|
||||||
|
// OS notifications
|
||||||
|
osNotificationsEnabled: z.boolean().default(false),
|
||||||
|
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||||
|
notifyOnNeedsInput: z.boolean().default(true),
|
||||||
|
notifyOnIdle: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
const RecentFolderSchema = z.object({
|
const RecentFolderSchema = z.object({
|
||||||
|
|||||||
@@ -423,7 +423,11 @@ async function main() {
|
|||||||
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
|
||||||
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
|
||||||
|
|
||||||
const localUrl = `${localProtocol}://localhost:${localStart.port}`
|
// Use an explicit IPv4 loopback address for the "local" URL.
|
||||||
|
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
|
||||||
|
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
|
||||||
|
// which can lead clients to talk to the wrong process.
|
||||||
|
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||||
let remoteUrl: string | undefined
|
let remoteUrl: string | undefined
|
||||||
if (remoteStart) {
|
if (remoteStart) {
|
||||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
|
|||||||
60
packages/tauri-app/Cargo.lock
generated
60
packages/tauri-app/Cargo.lock
generated
@@ -640,6 +640,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-keepawake",
|
"tauri-plugin-keepawake",
|
||||||
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"url",
|
"url",
|
||||||
@@ -1033,7 +1034,7 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading 0.7.4",
|
"libloading 0.8.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2336,6 +2337,18 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac-notification-sys"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"objc2 0.6.3",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -2540,6 +2553,20 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-rust"
|
||||||
|
version = "4.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
|
||||||
|
dependencies = [
|
||||||
|
"futures-lite 2.6.1",
|
||||||
|
"log",
|
||||||
|
"mac-notification-sys",
|
||||||
|
"serde",
|
||||||
|
"tauri-winrt-notification",
|
||||||
|
"zbus 5.12.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4390,6 +4417,25 @@ dependencies = [
|
|||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-notification"
|
||||||
|
version = "2.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"notify-rust",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.2"
|
version = "2.5.2"
|
||||||
@@ -4513,6 +4559,18 @@ dependencies = [
|
|||||||
"toml 0.9.8",
|
"toml 0.9.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||||
|
dependencies = [
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"windows 0.61.3",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.23.0"
|
version = "3.23.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ dirs = "5"
|
|||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
tauri-plugin-keepawake = "0.1.1"
|
tauri-plugin-keepawake = "0.1.1"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
"notification:allow-notify",
|
||||||
|
"notification:allow-show",
|
||||||
"core:webview:allow-set-webview-zoom"
|
"core:webview:allow-set-webview-zoom"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2408,6 +2408,204 @@
|
|||||||
"const": "keepawake:deny-stop",
|
"const": "keepawake:deny-stop",
|
||||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-batch",
|
||||||
|
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-cancel",
|
||||||
|
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-check-permissions",
|
||||||
|
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-create-channel",
|
||||||
|
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-delete-channel",
|
||||||
|
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-active",
|
||||||
|
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-pending",
|
||||||
|
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-is-permission-granted",
|
||||||
|
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-list-channels",
|
||||||
|
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-notify",
|
||||||
|
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-permission-state",
|
||||||
|
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-action-types",
|
||||||
|
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-listener",
|
||||||
|
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-remove-active",
|
||||||
|
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-request-permission",
|
||||||
|
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-show",
|
||||||
|
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-batch",
|
||||||
|
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-cancel",
|
||||||
|
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-check-permissions",
|
||||||
|
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-create-channel",
|
||||||
|
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-delete-channel",
|
||||||
|
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-active",
|
||||||
|
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-pending",
|
||||||
|
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-is-permission-granted",
|
||||||
|
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-list-channels",
|
||||||
|
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-notify",
|
||||||
|
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-permission-state",
|
||||||
|
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-action-types",
|
||||||
|
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-listener",
|
||||||
|
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-remove-active",
|
||||||
|
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-request-permission",
|
||||||
|
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-show",
|
||||||
|
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2408,6 +2408,204 @@
|
|||||||
"const": "keepawake:deny-stop",
|
"const": "keepawake:deny-stop",
|
||||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:default",
|
||||||
|
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-batch",
|
||||||
|
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-cancel",
|
||||||
|
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-check-permissions",
|
||||||
|
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-create-channel",
|
||||||
|
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-delete-channel",
|
||||||
|
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-active",
|
||||||
|
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-get-pending",
|
||||||
|
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-is-permission-granted",
|
||||||
|
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-list-channels",
|
||||||
|
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-notify",
|
||||||
|
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-permission-state",
|
||||||
|
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-action-types",
|
||||||
|
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-register-listener",
|
||||||
|
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-remove-active",
|
||||||
|
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-request-permission",
|
||||||
|
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:allow-show",
|
||||||
|
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the batch command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-batch",
|
||||||
|
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the cancel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-cancel",
|
||||||
|
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-check-permissions",
|
||||||
|
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-create-channel",
|
||||||
|
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-delete-channel",
|
||||||
|
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-active",
|
||||||
|
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-get-pending",
|
||||||
|
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-is-permission-granted",
|
||||||
|
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-list-channels",
|
||||||
|
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the notify command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-notify",
|
||||||
|
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-permission-state",
|
||||||
|
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-action-types",
|
||||||
|
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-register-listener",
|
||||||
|
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-remove-active",
|
||||||
|
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-request-permission",
|
||||||
|
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the show command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "notification:deny-show",
|
||||||
|
"markdownDescription": "Denies the show command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fn main() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_keepawake::init())
|
.plugin(tauri_plugin_keepawake::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
|
|||||||
@@ -354,32 +354,34 @@ const App: Component = () => {
|
|||||||
<Dialog open={Boolean(launchError())} modal>
|
<Dialog open={Boolean(launchError())} modal>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
{t("app.launchError.description")}
|
{t("app.launchError.description")}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
|
||||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||||
</div>
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={launchErrorMessage()}>
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
||||||
|
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
||||||
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={launchErrorMessage()}>
|
<div class="flex justify-end gap-2">
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<Show when={launchError()?.missingBinary}>
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
|
<button
|
||||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
type="button"
|
||||||
</div>
|
class="selector-button selector-button-secondary"
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Show when={launchError()?.missingBinary}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary"
|
|
||||||
onClick={handleLaunchErrorAdvanced}
|
onClick={handleLaunchErrorAdvanced}
|
||||||
>
|
>
|
||||||
{t("app.launchError.openAdvancedSettings")}
|
{t("app.launchError.openAdvancedSettings")}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||||
|
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||||
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -18,6 +22,21 @@ interface InstanceTabsProps {
|
|||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { preferences } = useConfig()
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||||
|
|
||||||
|
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||||
|
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||||
|
const notificationIcon = createMemo(() => {
|
||||||
|
if (!notificationsSupported()) return BellOff
|
||||||
|
return notificationsEnabled() ? Bell : BellOff
|
||||||
|
})
|
||||||
|
|
||||||
|
const notificationTitle = createMemo(() => {
|
||||||
|
if (!notificationsSupported()) return "Notifications unsupported"
|
||||||
|
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tab-bar tab-bar-instance">
|
<div class="tab-bar tab-bar-instance">
|
||||||
<div class="tab-container" role="tablist">
|
<div class="tab-container" role="tablist">
|
||||||
@@ -54,6 +73,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<ThemeModeToggle class="new-tab-button" />
|
<ThemeModeToggle class="new-tab-button" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||||
|
onClick={() => setNotificationsOpen(true)}
|
||||||
|
title={notificationTitle()}
|
||||||
|
aria-label={notificationTitle()}
|
||||||
|
>
|
||||||
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||||
<button
|
<button
|
||||||
class="new-tab-button tab-remote-button"
|
class="new-tab-button tab-remote-button"
|
||||||
@@ -67,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
const seenTimelineMessageIds = new Set<string>()
|
const seenTimelineMessageIds = new Set<string>()
|
||||||
const seenTimelineSegmentKeys = new Set<string>()
|
const seenTimelineSegmentKeys = new Set<string>()
|
||||||
|
const timelinePartCountsByMessageId = new Map<string, number>()
|
||||||
|
let pendingTimelineMessagePartUpdates = new Set<string>()
|
||||||
|
let pendingTimelinePartUpdateFrame: number | null = null
|
||||||
|
|
||||||
function makeTimelineKey(segment: TimelineSegment) {
|
function makeTimelineKey(segment: TimelineSegment) {
|
||||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||||
@@ -104,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
function seedTimeline() {
|
function seedTimeline() {
|
||||||
seenTimelineMessageIds.clear()
|
seenTimelineMessageIds.clear()
|
||||||
seenTimelineSegmentKeys.clear()
|
seenTimelineSegmentKeys.clear()
|
||||||
|
timelinePartCountsByMessageId.clear()
|
||||||
const ids = untrack(messageIds)
|
const ids = untrack(messageIds)
|
||||||
const resolvedStore = untrack(store)
|
const resolvedStore = untrack(store)
|
||||||
const segments: TimelineSegment[] = []
|
const segments: TimelineSegment[] = []
|
||||||
@@ -111,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const record = resolvedStore.getMessage(messageId)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
if (!record) return
|
if (!record) return
|
||||||
seenTimelineMessageIds.add(messageId)
|
seenTimelineMessageIds.add(messageId)
|
||||||
|
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
const key = makeTimelineKey(segment)
|
const key = makeTimelineKey(segment)
|
||||||
@@ -125,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
function appendTimelineForMessage(messageId: string) {
|
function appendTimelineForMessage(messageId: string) {
|
||||||
const record = untrack(() => store().getMessage(messageId))
|
const record = untrack(() => store().getMessage(messageId))
|
||||||
if (!record) return
|
if (!record) return
|
||||||
|
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
if (built.length === 0) return
|
if (built.length === 0) return
|
||||||
const newSegments: TimelineSegment[] = []
|
const newSegments: TimelineSegment[] = []
|
||||||
@@ -490,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let previousTimelineIds: string[] = []
|
let previousTimelineIds: string[] = []
|
||||||
let previousLastTimelineMessageId: string | null = null
|
|
||||||
let previousLastTimelinePartCount = 0
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const loading = Boolean(props.loading)
|
const loading = Boolean(props.loading)
|
||||||
@@ -499,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
previousTimelineIds = []
|
previousTimelineIds = []
|
||||||
previousLastTimelineMessageId = null
|
|
||||||
previousLastTimelinePartCount = 0
|
|
||||||
setTimelineSegments([])
|
setTimelineSegments([])
|
||||||
seenTimelineMessageIds.clear()
|
seenTimelineMessageIds.clear()
|
||||||
seenTimelineSegmentKeys.clear()
|
seenTimelineSegmentKeys.clear()
|
||||||
|
timelinePartCountsByMessageId.clear()
|
||||||
|
pendingTimelineMessagePartUpdates.clear()
|
||||||
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Keep part count tracking in sync with id replacement.
|
||||||
|
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||||
|
if (existingPartCount !== undefined) {
|
||||||
|
timelinePartCountsByMessageId.delete(oldId)
|
||||||
|
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||||
|
}
|
||||||
|
|
||||||
previousTimelineIds = ids.slice()
|
previousTimelineIds = ids.slice()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -568,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
previousTimelineIds = ids.slice()
|
previousTimelineIds = ids.slice()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function clearPendingTimelinePartUpdateFrame() {
|
||||||
|
if (pendingTimelinePartUpdateFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTimelinePartUpdateFlush() {
|
||||||
|
if (pendingTimelinePartUpdateFrame !== null) return
|
||||||
|
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
|
||||||
|
pendingTimelinePartUpdateFrame = null
|
||||||
|
if (pendingTimelineMessagePartUpdates.size === 0) return
|
||||||
|
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
|
||||||
|
pendingTimelineMessagePartUpdates = new Set<string>()
|
||||||
|
|
||||||
|
const ids = messageIds()
|
||||||
|
const resolvedStore = store()
|
||||||
|
|
||||||
|
setTimelineSegments((prev) => {
|
||||||
|
let next = prev
|
||||||
|
|
||||||
|
for (const changedId of changedIds) {
|
||||||
|
// Remove old segments for this message.
|
||||||
|
next = next.filter((segment) => segment.messageId !== changedId)
|
||||||
|
|
||||||
|
const record = resolvedStore.getMessage(changedId)
|
||||||
|
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
|
||||||
|
|
||||||
|
// Insert rebuilt segments in the correct place based on session message order.
|
||||||
|
if (rebuilt.length > 0) {
|
||||||
|
let insertAt = next.length
|
||||||
|
const changedIndex = ids.indexOf(changedId)
|
||||||
|
if (changedIndex >= 0) {
|
||||||
|
for (let i = changedIndex + 1; i < ids.length; i++) {
|
||||||
|
const followingId = ids[i]
|
||||||
|
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
insertAt = existingIndex
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the segment key set since we may have removed/replaced segments.
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep timeline segments in sync when message parts are added/removed.
|
||||||
|
// Part deletion does not remove message ids from the session, so we must
|
||||||
|
// explicitly replace segments for messages whose part count changed.
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.loading) return
|
if (props.loading) return
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
if (ids.length === 0) return
|
const resolvedStore = store()
|
||||||
const lastId = ids[ids.length - 1]
|
|
||||||
if (!lastId) return
|
let hasChanges = false
|
||||||
const record = store().getMessage(lastId)
|
for (const messageId of ids) {
|
||||||
if (!record) return
|
const record = resolvedStore.getMessage(messageId)
|
||||||
const partCount = record.partIds.length
|
const partCount = record?.partIds.length ?? 0
|
||||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||||
return
|
|
||||||
|
if (previousCount === undefined) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousCount !== partCount) {
|
||||||
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||||
|
pendingTimelineMessagePartUpdates.add(messageId)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
previousLastTimelineMessageId = lastId
|
|
||||||
previousLastTimelinePartCount = partCount
|
// Drop tracking for ids that are no longer present.
|
||||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||||
const newSegments: TimelineSegment[] = []
|
if (!ids.includes(trackedId)) {
|
||||||
built.forEach((segment) => {
|
timelinePartCountsByMessageId.delete(trackedId)
|
||||||
const key = makeTimelineKey(segment)
|
}
|
||||||
if (seenTimelineSegmentKeys.has(key)) return
|
}
|
||||||
seenTimelineSegmentKeys.add(key)
|
|
||||||
newSegments.push(segment)
|
if (hasChanges) {
|
||||||
})
|
scheduleTimelinePartUpdateFlush()
|
||||||
if (newSegments.length > 0) {
|
|
||||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -758,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
cancelAnimationFrame(pendingAnchorScroll)
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
}
|
}
|
||||||
clearScrollToBottomFrames()
|
clearScrollToBottomFrames()
|
||||||
|
clearPendingTimelinePartUpdateFrame()
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||||
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||||
let hoverTimer: number | null = null
|
let hoverTimer: number | null = null
|
||||||
|
let closeTimer: number | null = null
|
||||||
const showTools = () => props.showToolSegments ?? true
|
const showTools = () => props.showToolSegments ?? true
|
||||||
|
|
||||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||||
@@ -292,10 +293,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
hoverTimer = null
|
hoverTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearCloseTimer = () => {
|
||||||
|
if (closeTimer !== null && typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(closeTimer)
|
||||||
|
closeTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleClose = () => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
|
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||||
|
closeTimer = window.setTimeout(() => {
|
||||||
|
closeTimer = null
|
||||||
|
setHoveredSegment(null)
|
||||||
|
setHoverAnchorRect(null)
|
||||||
|
}, 160)
|
||||||
|
}
|
||||||
|
|
||||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
clearHoverTimer()
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
const target = event.currentTarget as HTMLButtonElement
|
const target = event.currentTarget as HTMLButtonElement
|
||||||
hoverTimer = window.setTimeout(() => {
|
hoverTimer = window.setTimeout(() => {
|
||||||
const rect = target.getBoundingClientRect()
|
const rect = target.getBoundingClientRect()
|
||||||
@@ -305,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
clearHoverTimer()
|
scheduleClose()
|
||||||
setHoveredSegment(null)
|
|
||||||
setHoverAnchorRect(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -326,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => clearHoverTimer())
|
onCleanup(() => {
|
||||||
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeId = props.activeMessageId
|
const activeId = props.activeMessageId
|
||||||
@@ -432,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
ref={(element) => setTooltipElement(element)}
|
ref={(element) => setTooltipElement(element)}
|
||||||
class="message-timeline-tooltip"
|
class="message-timeline-tooltip"
|
||||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
|
onMouseEnter={() => clearCloseTimer()}
|
||||||
|
onMouseLeave={() => scheduleClose()}
|
||||||
>
|
>
|
||||||
<MessagePreview
|
<MessagePreview
|
||||||
messageId={data().messageId}
|
messageId={data().messageId}
|
||||||
|
|||||||
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { Component, Show, createEffect, createResource } from "solid-js"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import {
|
||||||
|
getOsNotificationCapability,
|
||||||
|
requestOsNotificationPermission,
|
||||||
|
type OsNotificationPermission,
|
||||||
|
} from "../lib/os-notifications"
|
||||||
|
import { useConfig } from "../stores/preferences"
|
||||||
|
|
||||||
|
interface NotificationsSettingsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPermissionLabel(permission: OsNotificationPermission): string {
|
||||||
|
switch (permission) {
|
||||||
|
case "granted":
|
||||||
|
return "Granted"
|
||||||
|
case "denied":
|
||||||
|
return "Denied"
|
||||||
|
case "default":
|
||||||
|
return "Not granted"
|
||||||
|
case "unsupported":
|
||||||
|
return "Unsupported"
|
||||||
|
default:
|
||||||
|
return String(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationsSettingsModal: Component<NotificationsSettingsModalProps> = (props) => {
|
||||||
|
const { preferences, updatePreferences } = useConfig()
|
||||||
|
|
||||||
|
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEnableToggle = async (enabled: boolean) => {
|
||||||
|
if (!enabled) {
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message: cap.info ?? "OS notifications are not supported in this environment.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission !== "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? "Notification permission denied. Enable notifications in your system/browser settings."
|
||||||
|
: "Notification permission not granted.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreferences({ osNotificationsEnabled: true })
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestPermission = async () => {
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message: cap.info ?? "Notifications are not supported in this environment.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission === "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message: "Permission granted. You can now enable notifications.",
|
||||||
|
variant: "success",
|
||||||
|
duration: 6000,
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showToastNotification({
|
||||||
|
title: "Notifications",
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? "Permission denied. You may need to enable notifications in your system/browser settings."
|
||||||
|
: "Permission not granted.",
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = () => capability()?.supported ?? false
|
||||||
|
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported")
|
||||||
|
const infoMessage = () => capability()?.info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
|
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Notifications</Dialog.Title>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="panel-title">Session Status Notifications</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-primary">Enable</div>
|
||||||
|
<div class="text-xs text-secondary">Permission: {permissionLabel()}</div>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsEnabled)}
|
||||||
|
disabled={!supported() && capability.state === "ready"}
|
||||||
|
onChange={(e) => void handleEnableToggle(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-primary">Request permission</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => void handleRequestPermission()}
|
||||||
|
>
|
||||||
|
Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-primary">Notify when app is focused</div>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(e) => updatePreferences({ osNotificationsAllowWhenVisible: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={Boolean(infoMessage())}>
|
||||||
|
<div class="text-xs text-secondary">{infoMessage()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!supported() && capability.state === "ready"}>
|
||||||
|
<div class="text-xs text-secondary">
|
||||||
|
Notifications are not supported in this environment. The bell icon stays disabled.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="border-t pt-4" style={{ "border-color": "var(--border-base)" }}>
|
||||||
|
<div class="text-sm font-semibold text-primary mb-2">Notify me when</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-primary">Session needs input</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnNeedsInput)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(e) => updatePreferences({ notifyOnNeedsInput: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-primary">Session becomes idle</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnIdle)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(e) => updatePreferences({ notifyOnIdle: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||||
|
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationsSettingsModal
|
||||||
204
packages/ui/src/lib/os-notifications.ts
Normal file
204
packages/ui/src/lib/os-notifications.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { isElectronHost, isTauriHost } from "./runtime-env"
|
||||||
|
import { getLogger } from "./logger"
|
||||||
|
|
||||||
|
export type OsNotificationPermission = "granted" | "denied" | "default" | "unsupported"
|
||||||
|
|
||||||
|
export type OsNotificationCapability = {
|
||||||
|
supported: boolean
|
||||||
|
permission: OsNotificationPermission
|
||||||
|
info?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OsNotificationPayload = {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
function hasWebNotificationApi(): boolean {
|
||||||
|
return typeof window !== "undefined" && typeof (window as any).Notification !== "undefined"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebPermission(): OsNotificationPermission {
|
||||||
|
if (!hasWebNotificationApi()) return "unsupported"
|
||||||
|
const permission = (window as any).Notification.permission as string
|
||||||
|
if (permission === "granted") return "granted"
|
||||||
|
if (permission === "denied") return "denied"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWebPermission(): Promise<OsNotificationPermission> {
|
||||||
|
if (!hasWebNotificationApi()) return "unsupported"
|
||||||
|
try {
|
||||||
|
const next = await (window as any).Notification.requestPermission()
|
||||||
|
if (next === "granted") return "granted"
|
||||||
|
if (next === "denied") return "denied"
|
||||||
|
return "default"
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("[os-notifications] requestPermission failed", error)
|
||||||
|
return getWebPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWebNotification(payload: OsNotificationPayload): Promise<void> {
|
||||||
|
if (!hasWebNotificationApi()) {
|
||||||
|
throw new Error("Web notifications not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browsers generally require permission prior to sending.
|
||||||
|
if (getWebPermission() !== "granted") {
|
||||||
|
throw new Error("Web notification permission not granted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new (window as any).Notification(payload.title, { body: payload.body })
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasElectronNotifier(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
const api = (window as Window & { electronAPI?: any }).electronAPI
|
||||||
|
return Boolean(api && typeof api.showNotification === "function")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOsNotificationSupportedSync(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
if (isElectronHost()) {
|
||||||
|
return hasElectronNotifier()
|
||||||
|
}
|
||||||
|
if (isTauriHost()) {
|
||||||
|
// The authoritative check requires async import; treat Tauri as supported and let the
|
||||||
|
// settings modal surface missing plugin/capability errors.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasWebNotificationApi()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendElectronNotification(payload: OsNotificationPayload): Promise<void> {
|
||||||
|
const api = (window as Window & { electronAPI?: any }).electronAPI
|
||||||
|
if (!api || typeof api.showNotification !== "function") {
|
||||||
|
throw new Error("Electron notification bridge unavailable")
|
||||||
|
}
|
||||||
|
await api.showNotification(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTauriNotificationModule(): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const mod = await import("@tauri-apps/plugin-notification")
|
||||||
|
return mod
|
||||||
|
} catch (error) {
|
||||||
|
log.info("[os-notifications] tauri notification plugin not available", error as any)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTauriPermission(): Promise<OsNotificationPermission> {
|
||||||
|
const mod = await getTauriNotificationModule()
|
||||||
|
if (!mod) return "unsupported"
|
||||||
|
try {
|
||||||
|
const granted = await mod.isPermissionGranted()
|
||||||
|
return granted ? "granted" : "default"
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("[os-notifications] failed to check tauri notification permission", error)
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestTauriPermission(): Promise<OsNotificationPermission> {
|
||||||
|
const mod = await getTauriNotificationModule()
|
||||||
|
if (!mod) return "unsupported"
|
||||||
|
try {
|
||||||
|
const result = await mod.requestPermission()
|
||||||
|
if (result === "granted") return "granted"
|
||||||
|
if (result === "denied") return "denied"
|
||||||
|
return "default"
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("[os-notifications] failed to request tauri notification permission", error)
|
||||||
|
return await getTauriPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTauriNotification(payload: OsNotificationPayload): Promise<void> {
|
||||||
|
const mod = await getTauriNotificationModule()
|
||||||
|
if (!mod) {
|
||||||
|
throw new Error("Tauri notification plugin unavailable")
|
||||||
|
}
|
||||||
|
await mod.sendNotification({ title: payload.title, body: payload.body })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOsNotificationCapability(): Promise<OsNotificationCapability> {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { supported: false, permission: "unsupported", info: "Not available in this environment." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElectronHost()) {
|
||||||
|
if (!hasElectronNotifier()) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
permission: "unsupported",
|
||||||
|
info: "Electron notification bridge is not available.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electron notifications are controlled by OS-level settings; Electron doesn't expose a reliable permission probe.
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
permission: "granted",
|
||||||
|
info: "Notifications are managed by your OS notification settings.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriHost()) {
|
||||||
|
const permission = await getTauriPermission()
|
||||||
|
const supported = permission !== "unsupported"
|
||||||
|
return {
|
||||||
|
supported,
|
||||||
|
permission,
|
||||||
|
info: supported ? undefined : "Tauri notification support is not available in this build.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web
|
||||||
|
const permission = getWebPermission()
|
||||||
|
const supported = permission !== "unsupported"
|
||||||
|
return {
|
||||||
|
supported,
|
||||||
|
permission,
|
||||||
|
info: supported
|
||||||
|
? undefined
|
||||||
|
: "This browser does not support OS notifications (or notifications are blocked by the environment).",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOsNotificationPermission(): Promise<OsNotificationPermission> {
|
||||||
|
if (typeof window === "undefined") return "unsupported"
|
||||||
|
|
||||||
|
if (isElectronHost()) {
|
||||||
|
// Electron permissions are handled by the OS. No explicit request mechanism.
|
||||||
|
return hasElectronNotifier() ? "granted" : "unsupported"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriHost()) {
|
||||||
|
return await requestTauriPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
return await requestWebPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendOsNotification(payload: OsNotificationPayload): Promise<void> {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElectronHost()) {
|
||||||
|
await sendElectronNotification(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriHost()) {
|
||||||
|
await sendTauriNotification(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendWebNotification(payload)
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
fetchProviders,
|
fetchProviders,
|
||||||
clearInstanceDraftPrompts,
|
clearInstanceDraftPrompts,
|
||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded } from "./worktrees"
|
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||||
import { fetchCommands, clearCommands } from "./commands"
|
import { fetchCommands, clearCommands } from "./commands"
|
||||||
import { preferences } from "./preferences"
|
import { preferences } from "./preferences"
|
||||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||||
@@ -41,6 +41,8 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
|||||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||||
|
// Track which worktree a permission was enqueued under (by permission request id).
|
||||||
|
const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
|
||||||
|
|
||||||
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
||||||
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
||||||
@@ -676,6 +678,16 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
incrementSessionPendingCount(instanceId, sessionId)
|
incrementSessionPendingCount(instanceId, sessionId)
|
||||||
setSessionPendingPermission(instanceId, sessionId, true)
|
setSessionPendingPermission(instanceId, sessionId, true)
|
||||||
|
|
||||||
|
// Record the worktree slug at the time the permission is enqueued.
|
||||||
|
// This is used to respond in the same worktree context even from the global permission center.
|
||||||
|
const slug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||||
|
let byPermissionId = permissionWorktreeSlugByInstance.get(instanceId)
|
||||||
|
if (!byPermissionId) {
|
||||||
|
byPermissionId = new Map()
|
||||||
|
permissionWorktreeSlugByInstance.set(instanceId, byPermissionId)
|
||||||
|
}
|
||||||
|
byPermissionId.set(permission.id, slug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,6 +721,8 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
|||||||
|
|
||||||
const removed = removedPermission
|
const removed = removedPermission
|
||||||
if (removed) {
|
if (removed) {
|
||||||
|
// Use the id we were asked to remove (avoids type inference edge cases).
|
||||||
|
permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId)
|
||||||
const removedSessionId = getPermissionSessionId(removed)
|
const removedSessionId = getPermissionSessionId(removed)
|
||||||
if (removedSessionId) {
|
if (removedSessionId) {
|
||||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||||
@@ -729,6 +743,7 @@ function clearPermissionQueue(instanceId: string): void {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
clearSessionPendingCounts(instanceId)
|
clearSessionPendingCounts(instanceId)
|
||||||
|
permissionWorktreeSlugByInstance.delete(instanceId)
|
||||||
recomputeActiveInterruption(instanceId)
|
recomputeActiveInterruption(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,8 +892,13 @@ async function sendPermissionResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const stored = permissionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
|
||||||
|
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
|
||||||
|
const worktreeSlug = stored ?? fallback
|
||||||
|
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||||
|
|
||||||
await requestData(
|
await requestData(
|
||||||
instance.client.permission.reply({
|
client.permission.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
reply,
|
reply,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export interface Preferences {
|
|||||||
showUsageMetrics: boolean
|
showUsageMetrics: boolean
|
||||||
autoCleanupBlankSessions: boolean
|
autoCleanupBlankSessions: boolean
|
||||||
listeningMode: ListeningMode
|
listeningMode: ListeningMode
|
||||||
|
|
||||||
|
// OS notifications
|
||||||
|
osNotificationsEnabled: boolean
|
||||||
|
osNotificationsAllowWhenVisible: boolean
|
||||||
|
notifyOnNeedsInput: boolean
|
||||||
|
notifyOnIdle: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +91,11 @@ const defaultPreferences: Preferences = {
|
|||||||
showUsageMetrics: true,
|
showUsageMetrics: true,
|
||||||
autoCleanupBlankSessions: true,
|
autoCleanupBlankSessions: true,
|
||||||
listeningMode: "local",
|
listeningMode: "local",
|
||||||
|
|
||||||
|
osNotificationsEnabled: false,
|
||||||
|
osNotificationsAllowWhenVisible: false,
|
||||||
|
notifyOnNeedsInput: true,
|
||||||
|
notifyOnIdle: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +146,12 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
|||||||
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
||||||
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
|
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
|
||||||
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
|
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
|
||||||
|
|
||||||
|
osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultPreferences.osNotificationsEnabled,
|
||||||
|
osNotificationsAllowWhenVisible:
|
||||||
|
sanitized.osNotificationsAllowWhenVisible ?? defaultPreferences.osNotificationsAllowWhenVisible,
|
||||||
|
notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultPreferences.notifyOnNeedsInput,
|
||||||
|
notifyOnIdle: sanitized.notifyOnIdle ?? defaultPreferences.notifyOnIdle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,19 @@ import type { MessageStatus } from "./message-v2/types"
|
|||||||
|
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { requestData } from "../lib/opencode-api"
|
import { requestData } from "../lib/opencode-api"
|
||||||
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
|
import {
|
||||||
|
getPermissionId,
|
||||||
|
getPermissionKind,
|
||||||
|
getPermissionSessionId,
|
||||||
|
getRequestIdFromPermissionReply,
|
||||||
|
} from "../types/permission"
|
||||||
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
||||||
import { getQuestionId, getRequestIdFromQuestionReply } from "../types/question"
|
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
|
||||||
import type { QuestionRequest } from "../types/question"
|
import type { QuestionRequest } from "../types/question"
|
||||||
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
|
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
|
||||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||||
|
import { sendOsNotification } from "../lib/os-notifications"
|
||||||
|
import { preferences } from "./preferences"
|
||||||
import {
|
import {
|
||||||
instances,
|
instances,
|
||||||
addPermissionToQueue,
|
addPermissionToQueue,
|
||||||
@@ -57,6 +64,34 @@ import type { InstanceMessageStore } from "./message-v2/instance-store"
|
|||||||
const log = getLogger("sse")
|
const log = getLogger("sse")
|
||||||
const pendingSessionFetches = new Map<string, Promise<void>>()
|
const pendingSessionFetches = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
|
||||||
|
if (typeof document === "undefined") return false
|
||||||
|
const pref = preferences()
|
||||||
|
if (!pref.osNotificationsEnabled) return false
|
||||||
|
if (!pref.osNotificationsAllowWhenVisible && document.visibilityState === "visible") return false
|
||||||
|
if (kind === "needsInput") return Boolean(pref.notifyOnNeedsInput)
|
||||||
|
if (kind === "idle") return Boolean(pref.notifyOnIdle)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstanceDisplayName(instanceId: string): string {
|
||||||
|
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||||
|
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionTitle(instanceId: string, sessionId: string | undefined | null): string {
|
||||||
|
if (!sessionId) return ""
|
||||||
|
const session = sessions().get(instanceId)?.get(sessionId)
|
||||||
|
const title = session?.title?.trim()
|
||||||
|
return title && title.length > 0 ? title : sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireOsNotification(payload: { title: string; body: string }) {
|
||||||
|
void sendOsNotification(payload).catch((error) => {
|
||||||
|
log.warn("Failed to send OS notification", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
interface TuiToastEvent {
|
interface TuiToastEvent {
|
||||||
type: "tui.toast.show"
|
type: "tui.toast.show"
|
||||||
properties: {
|
properties: {
|
||||||
@@ -397,6 +432,13 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
|||||||
const sessionId = event.properties?.sessionID
|
const sessionId = event.properties?.sessionID
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
|
if (shouldSendOsNotification("idle")) {
|
||||||
|
const title = getInstanceDisplayName(instanceId)
|
||||||
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
|
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
||||||
|
fireOsNotification({ title, body })
|
||||||
|
}
|
||||||
|
|
||||||
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
|
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
|
||||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||||
}
|
}
|
||||||
@@ -504,6 +546,14 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
|
|||||||
log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
|
log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
|
||||||
addPermissionToQueue(instanceId, permission)
|
addPermissionToQueue(instanceId, permission)
|
||||||
upsertPermissionV2(instanceId, permission)
|
upsertPermissionV2(instanceId, permission)
|
||||||
|
|
||||||
|
if (shouldSendOsNotification("needsInput")) {
|
||||||
|
const title = getInstanceDisplayName(instanceId)
|
||||||
|
const sessionId = getPermissionSessionId(permission)
|
||||||
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
|
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
||||||
|
fireOsNotification({ title, body })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void {
|
function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void {
|
||||||
@@ -523,6 +573,14 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
|
|||||||
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
|
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
|
||||||
addQuestionToQueue(instanceId, request)
|
addQuestionToQueue(instanceId, request)
|
||||||
upsertQuestionV2(instanceId, request)
|
upsertQuestionV2(instanceId, request)
|
||||||
|
|
||||||
|
if (shouldSendOsNotification("needsInput")) {
|
||||||
|
const title = getInstanceDisplayName(instanceId)
|
||||||
|
const sessionId = getQuestionSessionId(request)
|
||||||
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
|
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
||||||
|
fireOsNotification({ title, body })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQuestionAnswered(
|
function handleQuestionAnswered(
|
||||||
|
|||||||
@@ -211,7 +211,8 @@
|
|||||||
.message-timeline-tooltip {
|
.message-timeline-tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
pointer-events: none;
|
/* Allow interacting with the preview (copy/delete/etc). */
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-preview {
|
.message-preview {
|
||||||
|
|||||||
4
packages/ui/src/types/global.d.ts
vendored
4
packages/ui/src/types/global.d.ts
vendored
@@ -25,8 +25,11 @@ declare global {
|
|||||||
onCliStatus?: (callback: (data: unknown) => void) => () => void
|
onCliStatus?: (callback: (data: unknown) => void) => () => void
|
||||||
onCliError?: (callback: (data: unknown) => void) => () => void
|
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||||
getCliStatus?: () => Promise<unknown>
|
getCliStatus?: () => Promise<unknown>
|
||||||
|
restartCli?: () => Promise<unknown>
|
||||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||||
|
|
||||||
|
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TauriDialogModule {
|
interface TauriDialogModule {
|
||||||
@@ -47,4 +50,3 @@ declare global {
|
|||||||
codenomadLogger?: LoggerControls
|
codenomadLogger?: LoggerControls
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,11 +53,15 @@ export default defineConfig({
|
|||||||
workbox: {
|
workbox: {
|
||||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||||
navigateFallback: null,
|
navigateFallback: null,
|
||||||
|
// Only precache static assets (avoid caching HTML documents / routes).
|
||||||
|
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
||||||
|
globIgnores: ["**/*.html"],
|
||||||
// Only cache static UI assets; never cache API traffic.
|
// Only cache static UI assets; never cache API traffic.
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: ({ url, request }) => {
|
urlPattern: ({ url, request }) => {
|
||||||
if (url.pathname.startsWith("/api/")) return false
|
if (url.pathname.startsWith("/api/")) return false
|
||||||
|
if (request.destination === "document") return false
|
||||||
return ["script", "style", "image", "font"].includes(request.destination)
|
return ["script", "style", "image", "font"].includes(request.destination)
|
||||||
},
|
},
|
||||||
handler: "CacheFirst",
|
handler: "CacheFirst",
|
||||||
|
|||||||
Reference in New Issue
Block a user