mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(desktop): follow OS language in packaged builds
Packaged Electron currently shows Open Design in en-US regardless of
the OS language setting, because the renderer's i18n picks its locale
from `navigator.language` and Chromium hard-codes that to en-US unless
the host process intervenes. Browser users and `tools-dev` users are
unaffected because their `navigator.language` already reflects the
OS / browser preference.
This change:
- Adds `applyOsLocaleSwitch(app)` in `@open-design/desktop/main`. It
reads `app.getPreferredSystemLanguages()[0]` and (when called before
Electron's `ready` event) points Chromium's `--lang` flag at it, so
the renderer's `navigator.language` follows the OS. Safe to call
more than once: `appendSwitch` is a no-op once `app.isReady()`.
- Calls the helper from both Electron entries: `apps/packaged` before
its own `whenReady`, and `runDesktopMain` for tools-dev parity.
- Forwards the resolved locale through
`BrowserWindow.webPreferences.additionalArguments` as
`--od-os-locale=<bcp-47>`, parsed by the preload and exposed at
`window.__od__.client.osLocale`. The host bridge type
(`OpenDesignHostClient.osLocale`) is extended accordingly.
- Updates `detectInitialLocale` in `apps/web/src/i18n/index.tsx` to
read that field as a new step between the existing localStorage and
navigator fallbacks. Browser/web continues to fall through to
`navigator.languages` unchanged.
The explicit `osLocale` channel exists in addition to `--lang` because
some `app.getPreferredSystemLanguages()` strings (e.g. `zh-Hant-TW`,
`pt-PT`) need to round-trip through `resolveSystemLocale` to land on
the right supported locale, which Chromium's `navigator.language`
cannot do on its own.
* fix(web): route OS locale read through getOpenDesignHost
The first cut of detectInitialLocale read `window.__od__.client.osLocale`
directly, which trips `tests/host-boundary.test.ts` — that guard test
keeps web source from referencing preload globals by name so the
boundary stays single-source. Switch to `getOpenDesignHost()` from
`@open-design/host`, and rewrite the i18n test to install the host via
`installMockOpenDesignHost` instead of poking the global directly.
* fix(tools-pack): unblock mac packaged build on pnpm workspaces
Two independent issues prevented `pnpm tools-pack mac build --to all`
from completing on a clean macOS workspace, both unrelated to the
desktop OS-locale change in this PR but bundled here because verifying
that change end-to-end required the packaged pipeline to actually
finish.
1. `apps/web/.next/standalone/node_modules/.pnpm/node_modules/<pkg>`
contained dangling symlinks left by Next's nft trace (e.g. a
`semver -> ../semver@5.7.2/node_modules/semver` link to a
`.pnpm/<pkg>@<ver>` directory pnpm never created). The downstream
`cp { dereference: true }` aborted the whole packaged pipeline
with ENOENT. Walk every artifact tree before copy and unlink
symlinks whose target doesn't resolve. Targets that *do* resolve
stay untouched.
2. Next 16's standalone build under pnpm workspaces does not hoist
peer-dep packages (react, react-dom, styled-jsx) into
`<standalone>/apps/web/node_modules`. The downstream
`web-standalone-after-pack.cjs` audit then does
`createRequire(server.js).resolve('react/package.json')`, whose
module walk falls out of the standalone tree and aborts the
electron-builder phase. Add a `hoistStandaloneNextPeerDeps` step
for the web standalone artifact only: it locates the
`<pkg>@<version>` (not peer-resolved sibling) directory under
`.pnpm` and symlinks it into `apps/web/node_modules/<pkg>`. The
subsequent `cp { dereference: true }` then writes the real
directory into the cache so the packaged tree stays self-contained.
Verified by `pnpm tools-pack mac build --to all` succeeding end-to-end
(zip + dmg + app), then `pnpm tools-pack mac install` and
`pnpm exec tools-pack mac inspect --expr` reading the desired
`__od__.client.osLocale` from the packaged renderer.
* feat(desktop): fold encodeURIComponent + manual locale source + pet window from #2554
Three defensive improvements lifted from @Eli-tangerine's parallel
implementation on #2554, kept consistent with the OS-locale chain
already on this branch:
- The argv value crossing main → preload is now wrapped with
encodeURIComponent / decodeURIComponent so a locale string with `;`,
`=`, or any other Chromium argv special char round-trips cleanly.
BCP-47 region tags don't carry those today, but the renderer parser
no longer has to assume it.
- `setLocale` now also writes `open-design:locale-source = "manual"`
to localStorage, and `detectInitialLocale` only treats the stored
locale as winning when that marker is present. An untagged value
(left over from a future auto-write path, or a stale install) no
longer pins the app to an old language once the host injects a
fresh OS locale. Today `setLocale` is the only writer so the marker
has no behaviour difference yet — this is a defensive net.
- `createDesktopPetWindow` now receives `osLocale` and forwards the
same `additionalArguments` as the main `BrowserWindow`, so the
pet renderer's `__od__.client.osLocale` is consistent with the main
window's instead of being silently undefined.
Co-authored idea credit: changes mirror the locale-piece of
@Eli-tangerine on #2554 — that PR is closing in favour of this one.
Tests: detect-initial-locale gets a new "untagged localStorage value
loses to host locale" case. desktop 62/62, host 13/13, web i18n +
host-boundary 15/15 stay green.
* feat(web): fold onboarding view styles from #2554
Pulls the 747-line addition to `apps/web/src/styles/home/entry-layout.css`
from @Eli-tangerine's #2554 — the visual layer for the global onboarding
flow (`/onboarding` view, Connect / About-you / Design-system steps).
The view itself was already plumbed through `EntryShell.tsx`; this adds
the styling that makes it shippable on v0.8.0.
#2554 is closing in favour of this branch, so the CSS lands here so the
onboarding work doesn't get dropped on the floor.
Co-authored idea credit: @Eli-tangerine — original styling on #2554.
* fix(tools-pack): make hoistStandaloneNextPeerDeps idempotent across builds
Addresses non-blocking review by @PerishCode on #2560: the previous
`if (await pathExists(linkPath)) continue;` guard uses `access()`,
which follows symlinks. A stale symlink from a previous build whose
`.pnpm/<pkg>@<version>` target moved (e.g. after a react/react-dom
version bump that invalidates the workspace-build cache key and forces
a re-run) reports as missing through `pathExists`, then `symlink()`
rejects with EEXIST and the unhandled rejection aborts the packaged
build.
Switch to `lstat` (which does not follow the link) so we can tell
"genuinely empty slot", "real directory left by Next" and "stale
symlink" apart, then unlink stale entries before re-creating. Also
move `stripBrokenSymlinks` ahead of `hoistStandaloneNextPeerDeps` in
`copyWorkspaceBuildArtifactsToCache` so any leftover dangling links
that survived a previous run are cleared before hoist tries to write.
229 lines
7.2 KiB
TypeScript
229 lines
7.2 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { de } from './locales/de';
|
|
import { en } from './locales/en';
|
|
import { id } from './locales/id';
|
|
import { esES } from './locales/es-ES';
|
|
import { fa } from './locales/fa';
|
|
import { ar } from './locales/ar';
|
|
import { ja } from './locales/ja';
|
|
import { ko } from './locales/ko';
|
|
import { ptBR } from './locales/pt-BR';
|
|
import { ru } from './locales/ru';
|
|
import { zhCN } from './locales/zh-CN';
|
|
import { zhTW } from './locales/zh-TW';
|
|
import { pl } from './locales/pl';
|
|
import { hu } from './locales/hu';
|
|
import { fr } from './locales/fr';
|
|
import { uk } from './locales/uk';
|
|
import { tr } from './locales/tr';
|
|
import { th } from './locales/th';
|
|
import { it } from './locales/it';
|
|
import { getOpenDesignHost } from '@open-design/host';
|
|
import { LOCALES, type Dict, type Locale } from './types';
|
|
|
|
export { LOCALES, LOCALE_LABEL } from './types';
|
|
export type { Locale } from './types';
|
|
|
|
type DictKey = keyof Dict;
|
|
|
|
const DICTS: Record<Locale, Dict> = {
|
|
'en': en,
|
|
'id': id,
|
|
'de': de,
|
|
'zh-CN': zhCN,
|
|
'zh-TW': zhTW,
|
|
'pt-BR': ptBR,
|
|
'es-ES': esES,
|
|
'ru': ru,
|
|
'fa': fa,
|
|
'ar': ar,
|
|
'ja': ja,
|
|
'ko': ko,
|
|
'pl': pl,
|
|
'hu': hu,
|
|
'fr': fr,
|
|
'uk': uk,
|
|
'tr': tr,
|
|
'th': th,
|
|
'it': it,
|
|
};
|
|
|
|
const LS_KEY = 'open-design:locale';
|
|
// Marker that says "the value in LS_KEY came from a deliberate user
|
|
// action through setLocale, not from some auto-detection path". Only
|
|
// values tagged this way win over the desktop host's injected OS
|
|
// locale, so a stale auto-detected pick can't pin the app forever once
|
|
// the user changes their system language.
|
|
const LS_SOURCE_KEY = 'open-design:locale-source';
|
|
const MANUAL_LOCALE_SOURCE = 'manual';
|
|
|
|
export function resolveSystemLocale(languages: readonly string[]): Locale | null {
|
|
const supported = LOCALES as readonly string[];
|
|
for (const raw of languages) {
|
|
const normalized = raw.trim();
|
|
if (!normalized) continue;
|
|
|
|
const exact = LOCALES.find((locale) => locale.toLowerCase() === normalized.toLowerCase());
|
|
if (exact) return exact;
|
|
|
|
const [language, regionOrScript] = normalized.toLowerCase().split('-');
|
|
if (language === 'zh') {
|
|
if (regionOrScript === 'hant' || regionOrScript === 'tw' || regionOrScript === 'hk' || regionOrScript === 'mo') {
|
|
return 'zh-TW';
|
|
}
|
|
return 'zh-CN';
|
|
}
|
|
|
|
const baseMatch = LOCALES.find((locale) => locale.toLowerCase().split('-')[0] === language);
|
|
if (baseMatch && supported.includes(baseMatch)) return baseMatch;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Read the OS locale the desktop host attached to its client descriptor.
|
|
// Packaged desktop builds need this because Chromium otherwise reports
|
|
// en-US through navigator.language regardless of the OS setting. We go
|
|
// through `getOpenDesignHost` rather than reading the bridge global by
|
|
// name so the web/preload boundary stays single-source (see the
|
|
// `host bridge boundary` guard test).
|
|
function readDesktopHostOsLocale(): string | undefined {
|
|
if (typeof window === 'undefined') return undefined;
|
|
const host = getOpenDesignHost();
|
|
const value = host?.client?.osLocale;
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
// First-run defaults to the user's OS / browser language when possible.
|
|
// Priority: explicit user pick saved to localStorage (only when tagged
|
|
// as manual) > OS locale that the desktop host injected (packaged
|
|
// Electron) > navigator.languages > 'en'. The source tag matters
|
|
// because untagged localStorage values are treated as legacy /
|
|
// auto-detected — they don't override a fresh OS locale read.
|
|
// Exported so tests can pin the priority chain without spinning up the
|
|
// full I18nProvider.
|
|
export function detectInitialLocale(): Locale {
|
|
if (typeof window === 'undefined') return 'en';
|
|
let storedLocale: string | null = null;
|
|
let storedSource: string | null = null;
|
|
try {
|
|
storedLocale = window.localStorage.getItem(LS_KEY);
|
|
storedSource = window.localStorage.getItem(LS_SOURCE_KEY);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (
|
|
storedSource === MANUAL_LOCALE_SOURCE &&
|
|
storedLocale &&
|
|
(LOCALES as string[]).includes(storedLocale)
|
|
) {
|
|
return storedLocale as Locale;
|
|
}
|
|
const hostOsLocale = readDesktopHostOsLocale();
|
|
if (hostOsLocale) {
|
|
const fromHost = resolveSystemLocale([hostOsLocale]);
|
|
if (fromHost) return fromHost;
|
|
}
|
|
const detected = resolveSystemLocale(
|
|
navigator.languages?.length ? navigator.languages : [navigator.language],
|
|
);
|
|
return detected ?? 'en';
|
|
}
|
|
|
|
interface I18nContextValue {
|
|
locale: Locale;
|
|
setLocale: (next: Locale) => void;
|
|
t: (key: DictKey, vars?: Record<string, string | number>) => string;
|
|
}
|
|
|
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
|
|
|
interface ProviderProps {
|
|
initial?: Locale;
|
|
children: ReactNode;
|
|
}
|
|
|
|
const RTL_LOCALES: Locale[] = ['ar', 'fa'];
|
|
|
|
export function I18nProvider({ initial, children }: ProviderProps) {
|
|
const [locale, setLocaleState] = useState<Locale>(() => initial ?? detectInitialLocale());
|
|
|
|
// Keep <html lang="…" dir="…"> in sync so screen readers and CSS hooks
|
|
// pick the right language token and direction without each component
|
|
// having to set it itself.
|
|
useEffect(() => {
|
|
if (typeof document !== 'undefined') {
|
|
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
|
|
document.documentElement.setAttribute('lang', locale);
|
|
document.documentElement.setAttribute('dir', dir);
|
|
}
|
|
}, [locale]);
|
|
|
|
const setLocale = useCallback((next: Locale) => {
|
|
setLocaleState(next);
|
|
try {
|
|
window.localStorage.setItem(LS_KEY, next);
|
|
// Marker so detectInitialLocale knows this came from a deliberate
|
|
// user action and should beat the desktop host's OS locale.
|
|
window.localStorage.setItem(LS_SOURCE_KEY, MANUAL_LOCALE_SOURCE);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
const t = useCallback(
|
|
(key: DictKey, vars?: Record<string, string | number>): string => {
|
|
const dict = DICTS[locale] ?? en;
|
|
const raw = dict[key] ?? en[key] ?? key;
|
|
if (!vars) return raw;
|
|
return raw.replace(/\{(\w+)\}/g, (_, name: string) => {
|
|
const v = vars[name];
|
|
return v == null ? `{${name}}` : String(v);
|
|
});
|
|
},
|
|
[locale],
|
|
);
|
|
|
|
const value = useMemo<I18nContextValue>(
|
|
() => ({ locale, setLocale, t }),
|
|
[locale, setLocale, t],
|
|
);
|
|
|
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
|
}
|
|
|
|
export function useI18n(): I18nContextValue {
|
|
const ctx = useContext(I18nContext);
|
|
if (!ctx) {
|
|
// Fall back to a stand-alone English translator when no provider is
|
|
// mounted (e.g. an isolated test). This keeps the API safe to call
|
|
// without requiring every callsite to wrap in a provider.
|
|
return {
|
|
locale: 'en',
|
|
setLocale: () => { },
|
|
t: (key, vars) => {
|
|
const raw = en[key] ?? key;
|
|
if (!vars) return raw;
|
|
return raw.replace(/\{(\w+)\}/g, (_, n: string) => {
|
|
const v = vars[n];
|
|
return v == null ? `{${n}}` : String(v);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
// Convenience for components that only need the translator function.
|
|
export function useT(): I18nContextValue['t'] {
|
|
return useI18n().t;
|
|
}
|