Compare commits

..

14 Commits

Author SHA1 Message Date
Shantur Rathore
50ccae8b27 fix(i18n): preserve global locale state across provider updates 2026-03-23 14:40:51 +00:00
Pascal André
f1ba699f9f refactor(ui): drop bootstrap config cache layer 2026-03-23 11:10:33 +01:00
Pascal André
0cb1c05903 refactor(ui): keep bootstrap config in global cache 2026-03-23 10:37:57 +01:00
Pascal André
b7ed232688 refactor(ui): reuse storage cache for bootstrap config 2026-03-23 10:11:13 +01:00
Pascal André
a442d53efa refactor(ui): move bootstrap cache sync out of preferences 2026-03-23 09:29:29 +01:00
Pascal André
8c0a82d3a8 refactor(ui): keep locale bootstrap branch focused 2026-03-19 21:21:00 +01:00
Pascal André
57efe5def3 fix(i18n): seed locale state from bootstrap preload 2026-03-19 21:17:46 +01:00
Pascal André
3710df916f fix(ui): harden bootstrap locale fallback 2026-03-19 21:17:46 +01:00
Pascal André
6f15ba2051 perf(ui): trim hidden session and bootstrap work 2026-03-19 21:17:46 +01:00
Pascal André
695c3fa954 perf(ui): defer locale and overlay bundles 2026-03-19 21:16:30 +01:00
Shantur Rathore
d735b189f5 refactor(tauri): use imported event and dialog APIs 2026-03-19 19:38:43 +00:00
Shantur Rathore
3d575f4f68 fix(tauri): align wake lock bridge with v2 API 2026-03-19 19:20:18 +00:00
Shantur Rathore
b58728dc0e add PR branch authorization workflows
Restrict non-dev pull requests to an allowlisted set of actors and skip cross-platform PR builds unless that authorization check passes. Keep dev open for general contributions while guiding other PRs back to the dev branch.
2026-03-19 15:01:36 +00:00
Shantur Rathore
672177f570 add PR build validation workflow
Run the full cross-platform build matrix on pull request creation and updates so build regressions are caught before merge without publishing release artifacts.
2026-03-19 14:52:48 +00:00
20 changed files with 1337 additions and 1625 deletions

52
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: PR Build Validation
on:
pull_request:
types:
- opened
- synchronize
- reopened
permissions:
contents: read
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
authorize:
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.auth.outputs.allowed }}
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check PR authorization
id: auth
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
fi
build:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' }}
uses: ./.github/workflows/build-and-upload.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
upload: false
set_versions: false

View File

@@ -0,0 +1,54 @@
name: Restrict Non-Dev PRs
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
permissions:
contents: read
pull-requests: write
jobs:
restrict-non-dev-prs:
if: ${{ github.event.pull_request.base.ref != 'dev' }}
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check allowed actor
id: auth
shell: bash
run: |
set -euo pipefail
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment on unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
- name: Close unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr close "$PR_NUMBER"
- name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
run: |
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1

26
package-lock.json generated
View File

@@ -3253,9 +3253,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.9.1", "version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==", "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -3322,6 +3322,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": { "node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
@@ -10235,14 +10244,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/tauri-plugin-keepawake-api": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -12098,6 +12099,8 @@
"@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/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3", "@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",
@@ -12110,7 +12113,6 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,11 @@ thiserror = "1"
anyhow = "1" anyhow = "1"
which = "4" which = "4"
libc = "0.2" libc = "0.2"
keepawake = "0.6"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
dirs = "5" dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
url = "2" url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]

File diff suppressed because one or more lines are too long

View File

@@ -2378,36 +2378,6 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"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`", "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", "type": "string",

View File

@@ -2378,36 +2378,6 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"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`", "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", "type": "string",

View File

@@ -3,8 +3,11 @@
mod cli_manager; mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus}; use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
@@ -26,9 +29,17 @@ static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(windows)] #[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
#[derive(Clone)]
pub struct AppState { pub struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
display: bool,
idle: bool,
sleep: bool,
} }
#[tauri::command] #[tauri::command]
@@ -47,6 +58,39 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status()) Ok(state.manager.status())
} }
#[tauri::command]
fn wake_lock_start(
state: tauri::State<AppState>,
config: Option<WakeLockConfig>,
) -> Result<(), String> {
let config = config.unwrap_or(WakeLockConfig {
display: true,
idle: false,
sleep: false,
});
let mut builder = keepawake::Builder::default();
builder
.display(config.display)
.idle(config.idle)
.sleep(config.sleep)
.reason("CodeNomad active session")
.app_name("CodeNomad")
.app_reverse_domain("ai.neuralnomads.codenomad.client");
let wake_lock = builder.create().map_err(|err| err.to_string())?;
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
*state_lock = Some(wake_lock);
Ok(())
}
#[tauri::command]
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
state_lock.take();
Ok(())
}
fn is_dev_mode() -> bool { fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
} }
@@ -137,11 +181,11 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.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_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
@@ -156,7 +200,12 @@ fn main() {
}); });
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart]) .invoke_handler(tauri::generate_handler![
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop
])
.on_menu_event(|app_handle, event| { .on_menu_event(|app_handle, event| {
match event.id().0.as_str() { match event.id().0.as_str() {
// File menu // File menu

View File

@@ -18,8 +18,10 @@
"@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-opener": "^2.5.3", "@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.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",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
@@ -30,7 +32,6 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -2,11 +2,6 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou
import type { ParentComponent } from "solid-js" import type { ParentComponent } from "solid-js"
import { useConfig } from "../../stores/preferences" import { useConfig } from "../../stores/preferences"
import { enMessages } from "./messages/en" import { enMessages } from "./messages/en"
import { esMessages } from "./messages/es"
import { frMessages } from "./messages/fr"
import { ruMessages } from "./messages/ru"
import { jaMessages } from "./messages/ja"
import { zhHansMessages } from "./messages/zh-Hans"
type Messages = Record<string, string> type Messages = Record<string, string>
@@ -15,14 +10,18 @@ export type TranslateParams = Record<string, unknown>
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
const messagesByLocale: Record<Locale, Messages> = { const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
en: enMessages, const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
es: esMessages,
fr: frMessages, const localeLoaders: Record<Locale, () => Promise<Messages>> = {
ru: ruMessages, en: async () => enMessages,
ja: jaMessages, es: async () => (await import("./messages/es")).esMessages,
"zh-Hans": zhHansMessages, fr: async () => (await import("./messages/fr")).frMessages,
ru: async () => (await import("./messages/ru")).ruMessages,
ja: async () => (await import("./messages/ja")).jaMessages,
"zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages,
} }
function normalizeLocaleTag(value: string): string { function normalizeLocaleTag(value: string): string {
@@ -34,8 +33,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
const normalized = normalizeLocaleTag(value) const normalized = normalizeLocaleTag(value)
const lower = normalized.toLowerCase() const lower = normalized.toLowerCase()
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])) const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
const exact = supportedLower.get(lower)
if (exact) return exact if (exact) return exact
const parts = lower.split("-") const parts = lower.split("-")
@@ -43,11 +41,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
if (!base) return null if (!base) return null
if (base === "zh") { if (base === "zh") {
const zhHans = supportedLower.get("zh-hans") const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
return zhHans ?? null return zhHans ?? null
} }
const baseMatch = supportedLower.get(base) const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
return baseMatch ?? null return baseMatch ?? null
} }
@@ -84,8 +82,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
} }
const [globalRevision, setGlobalRevision] = createSignal(0) const [globalRevision, setGlobalRevision] = createSignal(0)
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en" let globalMessages: Messages = enMessages
let globalMessages: Messages = messagesByLocale[initialGlobalLocale] let globalLocale: Locale = "en"
function getMessagesForLocale(locale: Locale): Messages {
return localeMessagesCache.get(locale) ?? enMessages
}
async function loadLocaleMessages(locale: Locale): Promise<Messages> {
const cached = localeMessagesCache.get(locale)
if (cached) {
return cached
}
const pending = localeMessagesPromises.get(locale)
if (pending) {
return pending
}
const loader = localeLoaders[locale]
const promise = loader()
.then((messages) => {
localeMessagesCache.set(locale, messages)
localeMessagesPromises.delete(locale)
return messages
})
.catch((error) => {
localeMessagesPromises.delete(locale)
throw error
})
localeMessagesPromises.set(locale, promise)
return promise
}
export async function preloadLocaleMessages(preferredLocale?: string | null): Promise<Locale> {
const resolvedLocale = matchSupportedLocale(preferredLocale ?? undefined) ?? detectNavigatorLocale() ?? "en"
try {
globalMessages = await loadLocaleMessages(resolvedLocale)
globalLocale = resolvedLocale
setGlobalRevision((value) => value + 1)
return resolvedLocale
} catch {
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
return "en"
}
}
export function tGlobal(key: string, params?: TranslateParams): string { export function tGlobal(key: string, params?: TranslateParams): string {
globalRevision() globalRevision()
@@ -101,9 +145,10 @@ const I18nContext = createContext<I18nContextValue>()
export const I18nProvider: ParentComponent = (props) => { export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig() const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en") const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
const previousMessages = globalMessages const previousGlobalMessages = globalMessages
const previousGlobalLocale = globalLocale
onMount(() => { onMount(() => {
const detected = detectNavigatorLocale() const detected = detectNavigatorLocale()
@@ -115,19 +160,44 @@ export const I18nProvider: ParentComponent = (props) => {
return configured ?? detectedLocale() ?? "en" return configured ?? detectedLocale() ?? "en"
}) })
const messages = createMemo<Messages>(() => messagesByLocale[locale()]) const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
function t(key: string, params?: TranslateParams): string { function t(key: string, params?: TranslateParams): string {
return translateFrom(messages(), key, params) return translateFrom(messages(), key, params)
} }
createEffect(() => { createEffect(() => {
globalMessages = messages() const nextLocale = locale()
setGlobalRevision((value) => value + 1) let cancelled = false
void loadLocaleMessages(nextLocale)
.then((loadedMessages) => {
if (cancelled) {
return
}
setResolvedLocale(nextLocale)
globalLocale = nextLocale
globalMessages = loadedMessages
setGlobalRevision((value) => value + 1)
})
.catch(() => {
if (cancelled) {
return
}
setResolvedLocale("en")
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
})
onCleanup(() => {
cancelled = true
})
}) })
onCleanup(() => { onCleanup(() => {
globalMessages = previousMessages globalMessages = previousGlobalMessages
globalLocale = previousGlobalLocale
setGlobalRevision((value) => value + 1) setGlobalRevision((value) => value + 1)
}) })

View File

@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
const log = getLogger("actions") const log = getLogger("actions")
@@ -15,9 +16,8 @@ export async function restartCli(): Promise<boolean> {
} }
if (runtimeEnv.host === "tauri") { if (runtimeEnv.host === "tauri") {
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__ if (typeof window.__TAURI__?.core?.invoke === "function") {
if (tauri?.invoke) { await invoke("cli_restart")
await tauri.invoke("cli_restart")
return true return true
} }
return false return false

View File

@@ -1,3 +1,4 @@
import { listen } from "@tauri-apps/api/event"
import { getLogger } from "../logger" import { getLogger } from "../logger"
import { runtimeEnv } from "../runtime-env" import { runtimeEnv } from "../runtime-env"
@@ -107,13 +108,8 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
return () => {} return () => {}
} }
const eventApi = window.__TAURI__?.event
if (!eventApi?.listen) {
return () => {}
}
try { try {
const unlisten = await eventApi.listen("desktop:folder-drop", (event) => { const unlisten = await listen("desktop:folder-drop", (event) => {
const payload = (event.payload ?? {}) as TauriFolderDropPayload const payload = (event.payload ?? {}) as TauriFolderDropPayload
const paths = normalizePathList(payload.paths) const paths = normalizePathList(payload.paths)
if (paths.length > 0) { if (paths.length > 0) {
@@ -134,15 +130,10 @@ export async function listenForNativeFolderDropState(onState: (state: NativeFold
return () => {} return () => {}
} }
const eventApi = window.__TAURI__?.event
if (!eventApi?.listen) {
return () => {}
}
try { try {
const [unlistenEnter, unlistenLeave] = await Promise.all([ const [unlistenEnter, unlistenLeave] = await Promise.all([
eventApi.listen("desktop:folder-drag-enter", () => onState("enter")), listen("desktop:folder-drag-enter", () => onState("enter")),
eventApi.listen("desktop:folder-drag-leave", () => onState("leave")), listen("desktop:folder-drag-leave", () => onState("leave")),
]) ])
return () => { return () => {
unlistenEnter() unlistenEnter()

View File

@@ -1,43 +1,21 @@
import { open } from "@tauri-apps/plugin-dialog"
import type { NativeDialogOptions } from "../native-functions" import type { NativeDialogOptions } from "../native-functions"
import { getLogger } from "../../logger" import { getLogger } from "../../logger"
const log = getLogger("actions") const log = getLogger("actions")
interface TauriDialogModule {
open?: (
options: {
title?: string
defaultPath?: string
filters?: { name?: string; extensions: string[] }[]
directory?: boolean
multiple?: boolean
},
) => Promise<string | string[] | null>
}
interface TauriBridge {
dialog?: TauriDialogModule
}
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> { export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return null return null
} }
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
const dialogApi = tauriBridge?.dialog
if (!dialogApi?.open) {
return null
}
try { try {
const response = await dialogApi.open({ const response = await open({
title: options.title, title: options.title,
defaultPath: options.defaultPath, defaultPath: options.defaultPath,
directory: options.mode === "directory", directory: options.mode === "directory",
multiple: false, multiple: false,
filters: options.filters?.map((filter) => ({ filters: options.filters?.map((filter) => ({
name: filter.name, name: filter.name ?? "Files",
extensions: filter.extensions, extensions: filter.extensions,
})), })),
}) })

View File

@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env" import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger" import { getLogger } from "../logger"
@@ -60,8 +61,7 @@ function hasAnyWakeLockSupport(): boolean {
if (api?.setWakeLock) return true if (api?.setWakeLock) return true
} }
if (runtimeEnv.host === "tauri") { if (runtimeEnv.host === "tauri") {
// We'll attempt dynamic import; treat as potentially supported. return typeof window.__TAURI__?.core?.invoke === "function"
return true
} }
return Boolean((navigator as any)?.wakeLock?.request) return Boolean((navigator as any)?.wakeLock?.request)
} }
@@ -84,21 +84,18 @@ async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
async function setTauriWakeLock(enabled: boolean): Promise<boolean> { async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
try { try {
const mod = await import("tauri-plugin-keepawake-api") if (!hasAnyWakeLockSupport()) {
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
const stop = (mod as any).stop as (() => Promise<void>) | undefined
if (!start || !stop) {
return false return false
} }
if (enabled) { if (enabled) {
// Plugin config supports toggling display/idle/sleep. Use a conservative // Match Electron's prevent-display-sleep behavior by keeping the display
// default to keep both system + display awake. // awake without blocking explicit system sleep requests.
await start({ display: true, idle: true, sleep: true }) await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } })
return true return true
} }
await stop() await invoke("wake_lock_stop")
return false return false
} catch (error) { } catch (error) {
log.log("[wake-lock] tauri wake lock failed", error) log.log("[wake-lock] tauri wake lock failed", error)
@@ -137,13 +134,12 @@ export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
inFlight = (async () => { inFlight = (async () => {
try { try {
const ok = await applyWakeLock(target) const ok = await applyWakeLock(target)
// Treat disable attempts as applied even if the underlying API doesn't exist. applied = target ? ok : false
applied = target
return ok return ok
} finally { } finally {
inFlight = null inFlight = null
// If desired changed while in-flight, re-apply once. // If desired changed while in-flight, re-apply once.
if (desired !== applied) { if (desired !== target) {
void setWakeLockDesired(desired) void setWakeLockDesired(desired)
} }

View File

@@ -9,17 +9,14 @@ export interface RuntimeEnvironment {
} }
declare global { declare global {
interface TauriCoreModule {
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
}
interface Window { interface Window {
electronAPI?: unknown electronAPI?: unknown
__TAURI__?: { __TAURI__?: {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> core?: TauriCoreModule
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
dialog?: {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
} }
} }
} }

View File

@@ -4,7 +4,7 @@ import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences" import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config" import { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { I18nProvider } from "./lib/i18n" import { I18nProvider, preloadLocaleMessages } from "./lib/i18n"
import { storage } from "./lib/storage" import { storage } from "./lib/storage"
import "./index.css" import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css" import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -31,15 +31,19 @@ async function bootstrap() {
try { try {
const uiConfig = await storage.loadConfigOwner("ui") const uiConfig = await storage.loadConfigOwner("ui")
const theme = (uiConfig as any)?.theme ?? "system" const theme = (uiConfig as any)?.theme
const locale = typeof (uiConfig as any)?.settings?.locale === "string" ? (uiConfig as any).settings.locale : undefined
if (theme === "system") { if (theme === "light" || theme === "dark") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme) document.documentElement.setAttribute("data-theme", theme)
} else {
document.documentElement.removeAttribute("data-theme")
} }
await preloadLocaleMessages(locale)
} catch { } catch {
// If config fails to load, fall back to CSS defaults. // If config fails to load, fall back to CSS defaults.
await preloadLocaleMessages()
} }
} }

View File

@@ -1,3 +1,5 @@
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Show, createSignal, onCleanup, onMount } from "solid-js" import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png" import iconUrl from "../../images/CodeNomad-Icon.png"
@@ -27,13 +29,6 @@ interface CliStatus {
error?: string | null error?: string | null
} }
interface TauriBridge {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
}
function pickPhraseKey(previous?: PhraseKey) { function pickPhraseKey(previous?: PhraseKey) {
const filtered = phraseKeys.filter((key) => key !== previous) const filtered = phraseKeys.filter((key) => key !== previous)
const source = filtered.length > 0 ? filtered : phraseKeys const source = filtered.length > 0 ? filtered : phraseKeys
@@ -46,17 +41,6 @@ function navigateTo(url?: string | null) {
window.location.replace(url) window.location.replace(url)
} }
function getTauriBridge(): TauriBridge | null {
if (typeof window === "undefined") {
return null
}
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
if (!bridge || !bridge.event || !bridge.invoke) {
return null
}
return bridge
}
function annotateDocument() { function annotateDocument() {
if (typeof document === "undefined") { if (typeof document === "undefined") {
return return
@@ -77,25 +61,22 @@ function LoadingApp() {
setPhraseKey(pickPhraseKey()) setPhraseKey(pickPhraseKey())
const unsubscribers: Array<() => void> = [] const unsubscribers: Array<() => void> = []
async function bootstrapTauri(tauriBridge: TauriBridge | null) { async function bootstrapTauri() {
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
return
}
try { try {
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => { const readyUnlisten = await listen("cli:ready", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
setError(null) setError(null)
setStatusKey(null) setStatusKey(null)
navigateTo(payload.url) navigateTo(payload.url)
}) })
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => { const errorUnlisten = await listen("cli:error", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
if (payload.error) { if (payload.error) {
setError(payload.error) setError(payload.error)
setStatusKey("loadingScreen.status.issue") setStatusKey("loadingScreen.status.issue")
} }
}) })
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => { const statusUnlisten = await listen("cli:status", (event) => {
const payload = (event?.payload as CliStatus) || {} const payload = (event?.payload as CliStatus) || {}
if (payload.state === "error" && payload.error) { if (payload.state === "error" && payload.error) {
setError(payload.error) setError(payload.error)
@@ -109,7 +90,7 @@ function LoadingApp() {
}) })
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten) unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
const result = await tauriBridge.invoke<CliStatus>("cli_get_status") const result = await invoke<CliStatus>("cli_get_status")
if (result?.state === "ready" && result.url) { if (result?.state === "ready" && result.url) {
navigateTo(result.url) navigateTo(result.url)
} else if (result?.state === "error" && result.error) { } else if (result?.state === "error" && result.error) {
@@ -123,7 +104,7 @@ function LoadingApp() {
} }
if (isTauriHost()) { if (isTauriHost()) {
void bootstrapTauri(getTauriBridge()) void bootstrapTauri()
} }
onCleanup(() => { onCleanup(() => {

View File

@@ -47,16 +47,9 @@ declare global {
webkitGetAsEntry?: () => FileSystemEntry | null webkitGetAsEntry?: () => FileSystemEntry | null
} }
interface TauriDialogModule {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
interface TauriBridge { interface TauriBridge {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> core?: {
dialog?: TauriDialogModule invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
} }
} }

View File

@@ -1,10 +0,0 @@
declare module "tauri-plugin-keepawake-api" {
export interface KeepAwakeConfig {
display?: boolean
idle?: boolean
sleep?: boolean
}
export function start(config?: KeepAwakeConfig): Promise<void>
export function stop(): Promise<void>
}