Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/main_server/web_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import asyncio
import os
import secrets
from urllib.parse import parse_qsl

import httpx
from fastapi import Request
Expand All @@ -34,11 +35,29 @@
logger = runtime.logger


def _has_generated_asset_version(query_string: bytes) -> bool:
"""Return whether ``v`` is a content-derived version safe to cache immutably."""
try:
query_params = parse_qsl(query_string.decode("ascii"), keep_blank_values=True)
except UnicodeDecodeError:
return False

for key, value in query_params:
if key != "v":
continue
version_tail = value.rsplit("-", 1)[-1]
if version_tail.isascii() and version_tail.isdigit() and len(version_tail) >= 9:
return True
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return False


class CustomStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
if path.endswith(".js"):
response.headers["Content-Type"] = "application/javascript"
if _has_generated_asset_version(scope.get("query_string", b"")):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit immutable caching to complete asset versions

Because this header is now applied solely from the shape of the v query, every ?v={{ static_asset_version }} URL must be covered by the timestamp that generated it. I checked templates/index.html and /static/social-embed.js?v={{ static_asset_version }} is emitted there, but social-embed.js is not part of _YUI_GUIDE_ASSET_VERSION_PATHS; after a patch that only changes that script while APP_VERSION and the tracked paths stay unchanged, clients that already cached it under the unchanged generated v can keep the old embed for up to a year. Either make the version depend on all versioned assets or only mark known tracked paths immutable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已处理,提交 56cbafc:将 static/social-embed.js 加入 _YUI_GUIDE_ASSET_VERSION_PATHS。现在该文件内容变化会更新 static_asset_version,避免继续复用旧的 immutable 缓存。

Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track every asset before serving it immutable

Fresh evidence after the current fixes: templates/index.html still serves assets such as /static/app/app-chat.js?v={{ static_asset_version }} (lines 411-412), but _YUI_GUIDE_ASSET_VERSION_PATHS only includes selected static/app files and the app-ui/app-interpage globs, not static/app/app-chat.js (main_routers/pages_router.py lines 97-103). With this new header, a release that changes only one of those untracked versioned files keeps the same generated v, so returning clients can reuse the old script for up to a year; either make the version input cover every static_asset_version URL or restrict immutable to known tracked assets.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已处理,感谢指出。之前版本仅维护了部分静态资源清单,导致模板中的 app-chat.js 等资源单独变更时版本号不会更新。现在 pages_router 会自动扫描 templates 中所有带 static_asset_version 的静态资源引用,并将实际存在的文件加入版本计算,同时保留运行时/非模板资源的手工清单。新增回归测试覆盖全部模板引用;修复已在提交 90cd5e0 中推送。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit immutable caching to version-owned assets

Fresh evidence after the template-scan fix: static/app/app-react-chat-window/cat-local-chat.js builds /static/assets/neko-idle/thought-items/cat1-chat-angry.gif?v=<react_chat_asset_version> from its own script URL, but _REACT_CHAT_ASSET_VERSION_PATHS in main_routers/pages_router.py does not include that GIF. With this broad Cache-Control, a release that changes only that image keeps the same numeric React-chat version, so clients that have already loaded the cat-local-chat sticker can reuse the old image for up to a year; include that asset in the React-chat version inputs or only mark paths owned by the version immutable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已处理,提交 7600458cat1-chat-angry.gif 现在加入 _REACT_CHAT_ASSET_VERSION_PATHS,因此由 cat-local-chat.js 继承的 react_chat_asset_version 会随该 GIF 的 mtime 更新;同时补充回归断言。定向 pytest 39/39、Ruff、Node 语法检查均通过。

return response


Expand Down
10 changes: 10 additions & 0 deletions launcher_core/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@
from pathlib import Path
from typing import Dict
from multiprocessing import Process, freeze_support, Event

# ``plugin/`` is also used as an import root for user-plugin processes and it
# contains a sibling ``config`` package. Keep the repository root first here,
# otherwise a long-lived test process (or an embedded plugin host) can resolve
# the launcher's top-level ``config`` imports to ``plugin.config`` instead.
_PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))
while _PROJECT_ROOT in sys.path:
sys.path.remove(_PROJECT_ROOT)
sys.path.insert(0, _PROJECT_ROOT)

import config as config_module
from config import APP_NAME, MAIN_SERVER_PORT, MEMORY_SERVER_PORT, TOOL_SERVER_PORT
from utils import parent_guard, single_instance
Expand Down
36 changes: 36 additions & 0 deletions main_routers/pages_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
enforced by ``scripts/check_api_trailing_slash.py``.
"""

import re
import time
import urllib.parse
from pathlib import Path

from fastapi import APIRouter, Request
Expand Down Expand Up @@ -55,6 +57,28 @@
_YUI_GUIDE_DIRECTOR_JS_PATHS = tuple(sorted(
(_PROJECT_ROOT / "static/tutorial/yui-guide/director").glob("*.js")
))
_STATIC_ASSET_VERSION_TEMPLATE_PATTERN = re.compile(
r"/static/([^\"'\s?]+)\?v=\{\{\s*static_asset_version\b"
)


def _template_static_asset_version_paths() -> tuple[Path, ...]:
"""Collect literal static files that templates serve with the version query."""
static_root = (_PROJECT_ROOT / "static").resolve()
paths: set[Path] = set()
for template_path in (_PROJECT_ROOT / "templates").glob("*.html"):
try:
template_source = template_path.read_text(encoding="utf-8")
except OSError:
continue
for match in _STATIC_ASSET_VERSION_TEMPLATE_PATTERN.finditer(template_source):
asset_path = (static_root / urllib.parse.unquote(match.group(1))).resolve()
if asset_path.is_relative_to(static_root) and asset_path.is_file():
paths.add(asset_path)
return tuple(sorted(paths))


_TEMPLATE_STATIC_ASSET_VERSION_PATHS = _template_static_asset_version_paths()
_YUI_GUIDE_ASSET_VERSION_PATHS = (
_PROJECT_ROOT / "static/css/yui-guide.css",
_PROJECT_ROOT / "static/css/tutorial-styles.css",
Expand Down Expand Up @@ -90,7 +114,9 @@
_PROJECT_ROOT / "static/live2d/live2d-ui-buttons.js",
*sorted(_PROJECT_ROOT.glob("static/vrm/*.js")),
_PROJECT_ROOT / "static/mmd/mmd-ui-buttons.js",
_PROJECT_ROOT / "static/mmd/mmd-init.js",
_PROJECT_ROOT / "static/pngtuber-core.js",
_PROJECT_ROOT / "static/social-embed.js",
_PROJECT_ROOT / "static/i18n-i18next.js",
_PROJECT_ROOT / "static/app/app-auto-goodbye.js",
_PROJECT_ROOT / "static/app/app-widget-mode.js",
Expand Down Expand Up @@ -153,10 +179,19 @@
_PROJECT_ROOT / "static/js/character_personality_onboarding.js",
_PROJECT_ROOT / "static/css/card_maker.css",
_PROJECT_ROOT / "static/js/card_maker.js",
_PROJECT_ROOT / "static/js/card_maker_embed_bootstrap.js",
_PROJECT_ROOT / "static/libs/live2dcubismcore.min.js",
_PROJECT_ROOT / "static/libs/live2d.min.js",
_PROJECT_ROOT / "static/libs/pixi.min.js",
_PROJECT_ROOT / "static/libs/index.min.js",
_PROJECT_ROOT / "static/live2d/live2d-core.js",
_PROJECT_ROOT / "static/live2d/live2d-emotion.js",
_PROJECT_ROOT / "static/live2d/live2d-model.js",
_PROJECT_ROOT / "static/js/voice_clone.js",
_PROJECT_ROOT / "static/css/model_manager.css",
*_MODEL_MANAGER_JS_PATHS,
*_TUTORIAL_RUNTIME_ASSET_PATHS,
*_TEMPLATE_STATIC_ASSET_VERSION_PATHS,
)
_STATIC_ASSET_CACHE_TTL = 30.0
_static_asset_version_cache: tuple[float, str] = (0.0, "0")
Expand All @@ -166,6 +201,7 @@
*_PROJECT_ROOT.glob("static/app/app-react-chat-window/*.js"),
_PROJECT_ROOT / "static/app/app-chat-adapter.js",
_PROJECT_ROOT / "static/app/app-buttons.js",
_PROJECT_ROOT / "static/assets/neko-idle/thought-items/cat1-chat-angry.gif",
*sorted(_PROJECT_ROOT.glob("static/assets/avatar-tools/**/*.png")),
*sorted(_PROJECT_ROOT.glob("static/sounds/avatar-tools/**/*.mp3")),
)
Expand Down
5 changes: 4 additions & 1 deletion static/app/app-ui/surface-floating-controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,15 @@
}, SOCIAL_OPEN_RELEASE_DELAY_MS);
}

// 猫娘网络(社交平台)按钮:占用原 screen 槽位。
// 喵宇宙(社交平台)按钮:占用原 screen 槽位。
// 从 /api/system/social/config 拿云端 base URL,从 /api/system/client-id 拿 device 身份。
// Electron:window.open → setWindowOpenHandler 识别 social feed,以带 OS chrome 的内置
// framed 子窗口打开(见 NEKO-PC pet-window-lifecycle)。浏览器:预开 about:blank 保手势。
// Desktop OAuth 仍走系统浏览器(loopback 回调 + 文案提示在浏览器完成登录)。
window.addEventListener('live2d-social-click', async () => {
if (window.nekoSocialUnlock && window.nekoSocialUnlock.isLocked()) {
return;
}
if (shouldIgnoreSocialOpenRequest()) {
return;
}
Expand Down
169 changes: 168 additions & 1 deletion static/avatar/avatar-ui-buttons/methods-buttons.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,151 @@
(function setupSocialUnlockState() {
const STORAGE_KEY = 'neko.social.unlock.v1';
const LOCK_DAYS = 3;
const DAY_MS = 24 * 60 * 60 * 1000;
let midnightTimer = null;
let localeListenerBound = false;
let fallbackFirstSeenDate = null;

function getLocalDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

function parseDateKey(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null;
}
return { year, month, day };
}

function getCalendarDayDelta(firstSeenDate, todayDate) {
const first = parseDateKey(firstSeenDate);
const today = parseDateKey(todayDate);
if (!first || !today) return 0;
const firstUtc = Date.UTC(first.year, first.month - 1, first.day);
const todayUtc = Date.UTC(today.year, today.month - 1, today.day);
return Math.max(0, Math.floor((todayUtc - firstUtc) / DAY_MS));
}

function readFirstSeenDate(todayDate) {
const today = getLocalDateKey(todayDate);
try {
const stored = window.localStorage && window.localStorage.getItem(STORAGE_KEY);
if (parseDateKey(stored)) return stored;
if (!fallbackFirstSeenDate) fallbackFirstSeenDate = today;
if (window.localStorage) window.localStorage.setItem(STORAGE_KEY, fallbackFirstSeenDate);
} catch (_) {
// localStorage may be unavailable in private or restricted contexts.
}
if (!fallbackFirstSeenDate) fallbackFirstSeenDate = today;
return fallbackFirstSeenDate;
}

function getStatus(todayDate = new Date()) {
const firstSeenDate = readFirstSeenDate(todayDate);
const today = getLocalDateKey(todayDate);
const dayDelta = getCalendarDayDelta(firstSeenDate, today);
return {
firstSeenDate,
dayDelta,
remainingDays: Math.max(0, LOCK_DAYS - dayDelta),
unlocked: dayDelta >= LOCK_DAYS
};
}

function getTitle(status) {
if (!status.unlocked) {
return window.t
? window.t('buttons.socialCharging', { days: status.remainingDays })
: `喵宇宙充能中 ${status.remainingDays}天后再回来看看吧`;
}
return window.t ? window.t('buttons.social') : '喵宇宙';
}

function applyButtonState(btn, imgOff, imgOn, status = getStatus()) {
if (!btn) return status;
const locked = !status.unlocked;
const title = getTitle(status);
btn.dataset.socialButton = 'true';
btn.dataset.socialLocked = locked ? 'true' : 'false';
btn.title = title;
btn.setAttribute('aria-disabled', locked ? 'true' : 'false');
btn.removeAttribute('data-i18n-title');

if (imgOff) imgOff.alt = title;
if (imgOn) imgOn.alt = title;

if (locked) {
btn.style.cursor = 'default';
btn.style.filter = 'grayscale(1)';
btn.style.opacity = '0.58';
btn.style.background = 'rgba(128, 128, 128, 0.45)';
btn.style.border = 'var(--neko-btn-border, 1px solid rgba(255, 255, 255, 0.18))';
btn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.04)';
btn.style.transform = 'scale(1)';
if (imgOff && imgOn) {
imgOff.style.opacity = '0.75';
imgOn.style.opacity = '0';
}
} else {
btn.style.cursor = 'pointer';
btn.style.filter = '';
btn.style.opacity = '';
btn.style.background = 'var(--neko-btn-bg, rgba(255, 255, 255, 0.65))';
btn.style.border = 'var(--neko-btn-border, 1px solid rgba(255, 255, 255, 0.18))';
btn.style.boxShadow = 'var(--neko-btn-shadow, 0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.08))';
}
return status;
}

function refreshButtons() {
const status = getStatus();
document.querySelectorAll('[data-social-button="true"]').forEach((btn) => {
const images = btn.querySelectorAll('img');
applyButtonState(btn, images[0], images[1], status);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (status.unlocked && midnightTimer) {
clearTimeout(midnightTimer);
midnightTimer = null;
}
return status;
}

function scheduleNextMidnight() {
if (midnightTimer) clearTimeout(midnightTimer);
const nextMidnight = new Date();
nextMidnight.setHours(24, 0, 0, 50);
midnightTimer = setTimeout(() => {
refreshButtons();
if (!getStatus().unlocked) scheduleNextMidnight();
}, Math.max(1000, nextMidnight.getTime() - Date.now()));
}

window.nekoSocialUnlock = window.nekoSocialUnlock || {
getStatus,
isUnlocked: (todayDate) => getStatus(todayDate).unlocked,
isLocked: (todayDate) => !getStatus(todayDate).unlocked,
applyButtonState,
refreshButtons,
registerButton: (btn, imgOff, imgOn) => {
const status = applyButtonState(btn, imgOff, imgOn);
if (!localeListenerBound) {
window.addEventListener('localechange', refreshButtons);
localeListenerBound = true;
}
if (!status.unlocked && !midnightTimer) scheduleNextMidnight();
return status;
}
};
})();

Object.assign(AvatarButtonMixin.methods, {
buttons(ManagerPrototype, prefix, options) {
ManagerPrototype.getDefaultButtonConfigs = function() {
Expand Down Expand Up @@ -40,7 +188,7 @@ Object.assign(AvatarButtonMixin.methods, {
{
// N.E.K.O.Servers 社交平台入口(替代桌面 screen 槽位)。
id: 'social',
title: window.t ? window.t('buttons.social') : '猫娘社区',
title: window.t ? window.t('buttons.social') : '喵宇宙',
titleKey: 'buttons.social',
hasPopup: false,
iconOff: `/static/icons/neko_community_off.png${iconVersion}`,
Expand Down Expand Up @@ -189,6 +337,17 @@ Object.assign(AvatarButtonMixin.methods, {
pointerEvents: 'auto'
});

if (config.id === 'social' && window.nekoSocialUnlock) {
// 捕获阶段拦截,保证各渲染器稍后注册的 click 监听器无法绕过锁定。
btn.addEventListener('click', (e) => {
if (window.nekoSocialUnlock.isLocked()) {
e.preventDefault();
e.stopImmediatePropagation();
}
}, true);
window.nekoSocialUnlock.registerButton(btn, imgOff, imgOn);
}

// 阻止按钮上的指针事件传播
const stopBtnEvent = (e) => { e.stopPropagation(); };
['pointerdown', 'pointermove', 'pointerup', 'mousedown', 'mousemove', 'mouseup', 'touchstart', 'touchmove', 'touchend'].forEach(evt => {
Expand All @@ -197,6 +356,10 @@ Object.assign(AvatarButtonMixin.methods, {

// 悬停效果
btn.addEventListener('mouseenter', () => {
if (config.id === 'social' && window.nekoSocialUnlock && window.nekoSocialUnlock.isLocked()) {
window.nekoSocialUnlock.applyButtonState(btn, imgOff, imgOn);
return;
}
btn.style.transform = 'scale(1.05)';
btn.style.boxShadow = 'var(--neko-btn-shadow-hover, 0 4px 8px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.08))';
btn.style.background = 'var(--neko-btn-bg-hover, rgba(255, 255, 255, 0.8))';
Expand All @@ -214,6 +377,10 @@ Object.assign(AvatarButtonMixin.methods, {
});

btn.addEventListener('mouseleave', () => {
if (config.id === 'social' && window.nekoSocialUnlock && window.nekoSocialUnlock.isLocked()) {
window.nekoSocialUnlock.applyButtonState(btn, imgOff, imgOn);
return;
}
btn.style.transform = 'scale(1)';
btn.style.boxShadow = 'var(--neko-btn-shadow, 0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.08))';
const isActive = btn.dataset.active === 'true';
Expand Down
4 changes: 2 additions & 2 deletions static/i18n-i18next.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@
const SUPPORTED_LANGUAGES = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'ru', 'es', 'pt'];

// locale 资源版本(用于 cache-busting,避免客户端长期缓存旧语言包导致新增 key 不生效)
// 修改原因:合并独立窗口置顶、社区入口与记忆浏览文案;递增版本让 Electron、
// 修改原因:合并独立窗口置顶、社区入口、记忆浏览和社交解锁文案;递增版本让 Electron、
// Docker 等长期缓存重新拉取包含完整新 key 的语言包。
const LOCALE_VERSION = '2026-07-27-merged-main-i18n';
const LOCALE_VERSION = '2026-08-04-social-unlock';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
function initDecorativeImageDragGuard() {
const markImage = (img) => {
if (!(img instanceof HTMLImageElement)) return;
Expand Down
Loading
Loading