6 Commits

Author SHA1 Message Date
rain9
0de417c165 merge: merge main 2025-12-19 09:09:09 +08:00
SteveLauC
f483ce4887 feat: resizable extension UI (#1009)
* wip

* define config entries: width/height/resizable/detachable

* chore: window size

* fix: add default values for ViewExtensionUiSettings fields

* chore: open

* chore: add window size set

* wip

* chore: window size

* define config entries: width/height/resizable/detachable

* chore: open

* fix: add default values for ViewExtensionUiSettings fields

* chore: add window size set

* chore: up

* fix: consle error

* chore: up

* chore: up

* chore: up

* chore: up

* refactor: update

* fix: page error about install

* chore: up

* chore: ci error

* docs: update release notes

* style: adjust styles

---------

Co-authored-by: rain9 <15911122312@163.com>
Co-authored-by: ayang <473033518@qq.com>
2025-12-19 09:01:51 +08:00
rain9
aee46caac8 chore: auth 2025-12-15 14:23:26 +08:00
rain9
fd710b0144 feat: add premissions settings 2025-12-15 11:07:35 +08:00
rain9
318ae938bb chore: add 2025-12-12 15:54:14 +08:00
rain9
16a4dbd7b0 chore: git 2025-12-12 14:37:06 +08:00
38 changed files with 1061 additions and 91 deletions

2
.gitignore vendored
View File

@@ -29,4 +29,4 @@ web.md
*.sw?
.env
.trae
.trae

View File

@@ -59,6 +59,7 @@
"serde",
"Shadcn",
"swatinem",
"systempreferences",
"tailwindcss",
"tauri",
"thiserror",

View File

@@ -13,6 +13,7 @@ Information about release notes of Coco App is provided here.
### 🚀 Features
- feat: resizable extension UI #1009
- feat: add open button to launch installed extension #1013
### 🐛 Bug fix

0
foo Normal file
View File

12
src-tauri/Cargo.lock generated
View File

@@ -1184,6 +1184,7 @@ dependencies = [
"scraper",
"semver",
"serde",
"serde-inline-default",
"serde_json",
"serde_plain",
"snafu",
@@ -6308,6 +6309,17 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-inline-default"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d48532bc0781ac622a5fea0f16502d3b4f1af0fcebe56d618120969f35d315"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"

View File

@@ -122,6 +122,7 @@ actix-web = "4.11.0"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-zustand = "1"
snafu = "0.8.9"
serde-inline-default = "1.0.0"
[dev-dependencies]
tempfile = "3.23.0"

View File

@@ -31,6 +31,11 @@
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:window:allow-set-position",
"core:window:allow-set-theme",
"core:window:allow-unminimize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-resizable",
"core:window:allow-maximize",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",
@@ -65,12 +70,10 @@
"fs-pro:default",
"macos-permissions:default",
"screenshots:default",
"core:window:allow-set-theme",
"process:default",
"updater:default",
"windows-version:default",
"log:default",
"opener:default",
"core:window:allow-unminimize"
"opener:default"
]
}

View File

@@ -152,14 +152,31 @@ pub struct Extension {
}
/// Settings that control the built-in UI Components
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct ViewExtensionUISettings {
/// Show the search bar
#[serde_inline_default(true)]
search_bar: bool,
/// Show the filter bar
#[serde_inline_default(true)]
filter_bar: bool,
/// Show the footer
#[serde_inline_default(true)]
footer: bool,
/// The recommended width of the window for this extension
width: Option<u32>,
/// The recommended heigh of the window for this extension
height: Option<u32>,
/// Is the extension window's size adjustable?
#[serde_inline_default(false)]
resizable: bool,
/// Detch the extension window from Coco's main window.
///
/// If true, user can click the detach button to open this
/// extension in a seprate window.
#[serde_inline_default(false)]
detachable: bool,
}
/// Bundle ID uniquely identifies an extension.

View File

@@ -2,12 +2,14 @@ mod assistant;
mod autostart;
mod common;
mod extension;
mod macos;
mod search;
mod selection_monitor;
mod server;
mod settings;
mod setup;
mod shortcut;
// We need this in main.rs, so it has to be pub
pub mod util;
@@ -206,6 +208,10 @@ pub fn run() {
util::logging::app_log_dir,
selection_monitor::set_selection_enabled,
selection_monitor::get_selection_enabled,
macos::permissions::check_accessibility_trusted,
macos::permissions::open_accessibility_settings,
macos::permissions::open_screen_recording_settings,
macos::permissions::open_microphone_settings,
])
.setup(|app| {
#[cfg(target_os = "macos")]

View File

@@ -0,0 +1 @@
pub mod permissions;

View File

@@ -0,0 +1,58 @@
#[tauri::command]
pub fn check_accessibility_trusted() -> bool {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
let trusted = macos_accessibility_client::accessibility::application_is_trusted();
log::info!(target: "coco_lib::permissions", "check_accessibility_trusted invoked: {}", trusted);
trusted
} else {
log::info!(target: "coco_lib::permissions", "check_accessibility_trusted invoked on non-macOS: false");
false
}
}
}
#[tauri::command]
pub fn open_accessibility_settings() {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use std::process::Command;
log::info!(target: "coco_lib::permissions", "open_accessibility_settings invoked");
let _ = Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.status();
} else {
// no-op on non-macOS
}
}
}
#[tauri::command]
pub fn open_screen_recording_settings() {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use std::process::Command;
log::info!(target: "coco_lib::permissions", "open_screen_recording_settings invoked");
let _ = Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording")
.status();
} else {
// no-op on non-macOS
}
}
}
#[tauri::command]
pub fn open_microphone_settings() {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
use std::process::Command;
log::info!(target: "coco_lib::permissions", "open_microphone_settings invoked");
let _ = Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
.status();
} else {
// no-op on non-macOS
}
}
}

View File

@@ -2,6 +2,7 @@
/// Coordinates use logical (Quartz) points with a top-left origin.
/// Note: `y` is flipped on the backend to match the frontends usage.
use tauri::Emitter;
use tauri::Manager;
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
@@ -14,8 +15,8 @@ use once_cell::sync::Lazy;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
/// Global toggle: selection monitoring disabled for this release.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(false);
/// Global toggle: selection monitoring enabled for this release.
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);
/// Ensure we only start the monitor thread once. Allows delayed start after
/// Accessibility permission is granted post-launch.
@@ -24,6 +25,9 @@ static MONITOR_THREAD_STARTED: AtomicBool = AtomicBool::new(false);
/// Guard to avoid spawning multiple permission watcher threads.
#[cfg(target_os = "macos")]
static PERMISSION_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
/// Guard to avoid spawning multiple selection store watcher threads.
#[cfg(target_os = "macos")]
static SELECTION_STORE_WATCHER_STARTED: AtomicBool = AtomicBool::new(false);
/// Session flags for controlling macOS Accessibility prompts.
#[cfg(target_os = "macos")]
@@ -31,6 +35,8 @@ static SEEN_ACCESSIBILITY_TRUSTED_ONCE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
static LAST_ACCESSIBILITY_PROMPT: Lazy<Mutex<Option<std::time::Instant>>> =
Lazy::new(|| Mutex::new(None));
#[cfg(target_os = "macos")]
static LAST_READ_WARN: Lazy<Mutex<Option<std::time::Instant>>> = Lazy::new(|| Mutex::new(None));
#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
@@ -95,7 +101,19 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
use tauri::Emitter;
// Sync initial enabled state to the frontend on startup.
set_selection_enabled_internal(&app_handle, is_selection_enabled());
// Prefer disk-persisted Zustand store if present
#[cfg(target_os = "macos")]
ensure_selection_store_bootstrap(&app_handle);
if let Some(enabled) = read_selection_enabled_from_store(&app_handle) {
log::info!(target: "coco_lib::selection_monitor", "initial selection-enabled loaded from store: {}", enabled);
set_selection_enabled_internal(&app_handle, enabled);
} else {
log::warn!(target: "coco_lib::selection_monitor", "initial selection-enabled not found in store, falling back to in-memory flag");
set_selection_enabled_internal(&app_handle, is_selection_enabled());
}
// Start a light watcher to keep SELECTION_ENABLED in sync with disk
start_selection_store_watcher(app_handle.clone());
log::info!(target: "coco_lib::selection_monitor", "selection store watcher started");
// Accessibility permission is required to read selected text in the foreground app.
// If not granted, prompt the user once; if still not granted, skip starting the watcher.
@@ -287,6 +305,7 @@ pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
// If disabled: do not read AX / do not show popup; hide if currently visible.
if !is_selection_enabled() {
log::debug!(target: "coco_lib::selection_monitor", "monitor loop: selection disabled");
if popup_visible {
let _ = app_handle.emit("selection-detected", "");
popup_visible = false;
@@ -444,6 +463,118 @@ fn ensure_accessibility_permission(app_handle: &tauri::AppHandle) -> bool {
false
}
/// Resolve the path to the zustand store file `selection-store.json`.
#[cfg(target_os = "macos")]
fn selection_store_path(app_handle: &tauri::AppHandle) -> std::path::PathBuf {
let mut dir = app_handle
.path()
.app_data_dir()
.expect("failed to find the local dir");
dir.push("zustand");
dir.push("selection-store.json");
log::debug!(target: "coco_lib::selection_monitor", "selection_store_path resolved: {}", dir.display());
dir
}
#[cfg(target_os = "macos")]
fn ensure_selection_store_bootstrap(app_handle: &tauri::AppHandle) {
use std::fs;
use std::io::Write;
let mut dir = app_handle
.path()
.app_data_dir()
.expect("failed to find the local dir");
dir.push("zustand");
let _ = fs::create_dir_all(&dir);
let file = dir.join("selection-store.json");
if !file.exists() {
let initial = serde_json::json!({
"selectionEnabled": true,
"iconsOnly": false,
"toolbarConfig": []
});
if let Ok(mut f) = fs::File::create(&file) {
let _ = f.write_all(
serde_json::to_string(&initial)
.unwrap_or_else(|_| "{}".to_string())
.as_bytes(),
);
log::info!(target: "coco_lib::selection_monitor", "bootstrap selection-store.json created: {}", file.display());
}
}
}
/// Read `selectionEnabled` from the persisted zustand store.
/// Returns Some(bool) if read succeeds; None otherwise.
#[cfg(target_os = "macos")]
fn read_selection_enabled_from_store(app_handle: &tauri::AppHandle) -> Option<bool> {
use std::fs;
let path = selection_store_path(app_handle);
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(v) => {
let val = v.get("selectionEnabled").and_then(|b| b.as_bool());
log::info!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: {} -> {:?}", path.display(), val);
val
}
Err(e) => {
log::warn!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: JSON parse failed for {}: {}", path.display(), e);
None
}
},
Err(e) => {
use std::time::Duration;
use std::time::Instant;
let mut last = LAST_READ_WARN.lock().unwrap();
let now = Instant::now();
let allow = match *last {
Some(ts) => now.duration_since(ts) > Duration::from_secs(30),
None => true,
};
if allow {
log::warn!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: read failed for {}: {}", path.display(), e);
*last = Some(now);
} else {
log::debug!(target: "coco_lib::selection_monitor", "read_selection_enabled_from_store: read failed suppressed for {}", path.display());
}
None
}
}
}
/// Spawn a background watcher to sync `SELECTION_ENABLED` with disk every ~1s.
#[cfg(target_os = "macos")]
fn start_selection_store_watcher(app_handle: tauri::AppHandle) {
if SELECTION_STORE_WATCHER_STARTED.swap(true, Ordering::Relaxed) {
return;
}
std::thread::Builder::new()
.name("selection-store-watcher".into())
.spawn(move || {
use std::time::{Duration, Instant};
let mut last_check = Instant::now();
let mut last_val: Option<bool> = None;
loop {
// Check approximately every second
if last_check.elapsed() >= Duration::from_secs(1) {
let current = read_selection_enabled_from_store(&app_handle);
if current.is_some() && current != last_val {
let enabled = current.unwrap();
set_selection_enabled_internal(&app_handle, enabled);
log::info!(target: "coco_lib::selection_monitor", "selection-store-watcher: detected change, enabled={}", enabled);
last_val = current;
}
last_check = Instant::now();
}
std::thread::sleep(Duration::from_millis(200));
}
})
.unwrap_or_else(|e| {
SELECTION_STORE_WATCHER_STARTED.store(false, Ordering::Relaxed);
panic!("selection-store-watcher: failed to spawn: {}", e);
});
}
#[cfg(target_os = "macos")]
fn collect_selection_permission_info() -> SelectionPermissionInfo {
let exe_path = std::env::current_exe()

View File

@@ -32,7 +32,7 @@ pub fn platform(
let panel = main_window.to_panel::<NsPanel>().unwrap();
// set level
panel.set_level(PanelLevel::Utility.value());
panel.set_level(PanelLevel::Dock.value());
// Do not steal focus from other windows
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());

View File

@@ -110,6 +110,7 @@ pub(crate) async fn backend_setup(tauri_app_handle: AppHandle, app_lang: String)
// Start system-wide selection monitor (macOS-only currently)
#[cfg(target_os = "macos")]
{
log::info!("backend_setup: starting system-wide selection monitor");
crate::selection_monitor::start_selection_monitor(tauri_app_handle.clone());
}

View File

@@ -20,7 +20,7 @@
"width": 680,
"decorations": false,
"minimizable": false,
"maximizable": false,
"maximizable": true,
"skipTaskbar": true,
"resizable": false,
"acceptFirstMouse": true,

View File

@@ -57,13 +57,13 @@ const ErrorNotification = ({
>
<div className="flex items-center">
{visibleError.type === "error" && (
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
<AlertCircle className="size-5 shrink-0 text-red-500 mr-2" />
)}
{visibleError.type === "warning" && (
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
<AlertTriangle className="size-5 shrink-0 text-yellow-500 mr-2" />
)}
{visibleError.type === "info" && (
<Info className="w-5 h-5 text-blue-500 mr-2" />
<Info className="size-5 shrink-0 text-blue-500 mr-2" />
)}
<span className="text-sm text-gray-700 dark:text-gray-200">
@@ -78,7 +78,7 @@ const ErrorNotification = ({
</div>
<X
className="w-5 h-5 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
className="size-5 shrink-0 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
onClick={() => removeError(visibleError.id)}
/>
</div>

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useMemo, useState, useEffect } from "react";
import { cloneDeep, isEmpty } from "lodash-es";
import { useKeyPress } from "ahooks";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
@@ -8,7 +9,6 @@ import { Get } from "@/api/axiosRequest";
import type { Assistant } from "@/types/chat";
import { useAppStore } from "@/stores/appStore";
import { canNavigateBack, navigateBack } from "@/utils";
import { useKeyPress } from "ahooks";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AssistantManagerProps {
@@ -167,7 +167,7 @@ export function useAssistantManager({
const { selectedSearchContent, visibleExtensionStore } =
useSearchStore.getState();
console.log("selectedSearchContent", selectedSearchContent);
// console.log("selectedSearchContent", selectedSearchContent);
const { id, type, category } = selectedSearchContent ?? {};

View File

@@ -118,7 +118,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
</div>
<div className="flex items-center gap-1">
<FolderDown className="size-4" />
<span>{selectedExtension.stats.installs}</span>
<span>{selectedExtension.stats?.installs ?? 0}</span>
</div>
</div>
</div>

View File

@@ -348,7 +348,7 @@ const ExtensionStore = ({
<div className="flex items-center gap-1 text-[#999]">
<FolderDown className="size-4" />
<span>{stats.installs}</span>
<span>{stats?.installs ?? 0}</span>
</div>
</div>
</div>

View File

@@ -14,7 +14,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
import { parseSearchQuery, SearchQuery, canNavigateBack } from "@/utils";
import InputUpload from "./InputUpload";
import Copyright from "../Common/Copyright";
@@ -283,7 +283,7 @@ const InputControls = ({
</div>
)}
{isChatPage || hasModules?.length !== 2 ? null : (
{isChatPage || hasModules?.length !== 2 || canNavigateBack() ? null : (
<div className="relative w-16 flex justify-end items-center">
<div className="absolute right-[52px] -top-2 z-10">
<VisibleKey

View File

@@ -1,19 +1,47 @@
import React from "react";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Maximize2, Minimize2, Focus } from "lucide-react";
import { useSearchStore } from "@/stores/searchStore";
import {
ExtensionFileSystemPermission,
FileSystemAccess,
ViewExtensionUISettings,
} from "../Settings/Extensions";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { isMac } from "@/utils/platform";
import { useAppStore } from "@/stores/appStore";
const ViewExtension: React.FC = () => {
const { viewExtensionOpened } = useSearchStore();
const isTauri = useAppStore((state) => state.isTauri);
// Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
const { setModifierKeyPressed } = useShortcutsStore();
const { t } = useTranslation();
const [isFullscreen, setIsFullscreen] = useState(false);
const prevWindowRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const fullscreenPrevRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const DEFAULT_VIEW_WIDTH = 1200;
const DEFAULT_VIEW_HEIGHT = 900;
const [scale, setScale] = useState(1);
if (viewExtensionOpened == null) {
// When this view gets loaded, this state should not be NULL.
@@ -156,7 +184,6 @@ const ViewExtension: React.FC = () => {
}
};
window.addEventListener("message", messageHandler);
console.info("Coco extension API listener is up");
return () => {
window.removeEventListener("message", messageHandler);
@@ -164,15 +191,233 @@ const ViewExtension: React.FC = () => {
}, [reversedApis, permission]); // Add apiPermissions as dependency
const fileUrl = viewExtensionOpened[2];
const ui: ViewExtensionUISettings | undefined = useMemo(() => {
return viewExtensionOpened[4] as ViewExtensionUISettings | undefined;
}, [viewExtensionOpened]);
const resizable = ui?.resizable;
const baseWidth = useMemo(() => {
return ui && typeof ui?.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
}, [ui]);
const baseHeight = useMemo(() => {
return ui && typeof ui?.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
}, [ui]);
const recomputeScale = useCallback(async () => {
const size = await platformAdapter.getWindowSize();
const nextScale = Math.min(size.width / baseWidth, size.height / baseHeight);
setScale(Math.max(nextScale, 0.1));
}, [baseWidth, baseHeight]);
const applyFullscreen = useCallback(
async (next: boolean) => {
if (next) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
fullscreenPrevRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
if (isMac && isTauri) {
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await platformAdapter.getCurrentWebviewWindow();
const factor = await window.scaleFactor();
const { size, position } = monitor;
const { width, height } = size.toLogical(factor);
const { x, y } = position.toLogical(factor);
await platformAdapter.setWindowSize(width, height);
await platformAdapter.setWindowPosition(x, y);
await platformAdapter.setWindowResizable(true);
await recomputeScale();
} else {
await platformAdapter.setWindowFullscreen(true);
await recomputeScale();
}
} else {
if (!isMac) {
await platformAdapter.setWindowFullscreen(false);
}
const nextWidth =
ui && typeof ui.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
const nextHeight =
ui && typeof ui.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(nextWidth, nextHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
}
},
[ui, recomputeScale]
);
useEffect(() => {
const applyWindowSettings = async () => {
if (viewExtensionOpened != null) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
prevWindowRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
const nextWidth =
ui && typeof ui.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
const nextHeight =
ui && typeof ui.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(nextWidth, nextHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
} else {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
await platformAdapter.setWindowSize(prev.width, prev.height);
await platformAdapter.setWindowResizable(prev.resizable);
await platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
}, 0);
}
}
};
applyWindowSettings();
return () => {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
platformAdapter.setWindowSize(prev.width, prev.height);
platformAdapter.setWindowResizable(prev.resizable);
platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
}
};
}, [viewExtensionOpened]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
applyFullscreen(false);
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
} as any);
};
}, [isFullscreen, applyFullscreen]);
return (
<iframe
src={fileUrl}
className="w-full h-full border-0"
onLoad={(event) => {
event.currentTarget.focus();
}}
/>
<div className="relative w-full h-full">
{isFullscreen && <div className="absolute inset-0 pointer-events-none" />}
{resizable && (
<button
aria-label={
isFullscreen
? t("viewExtension.fullscreen.exit")
: t("viewExtension.fullscreen.enter")
}
className="absolute top-2 right-2 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={async () => {
const next = !isFullscreen;
await applyFullscreen(next);
setIsFullscreen(next);
if (next) {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}
}}
>
{isFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</button>
)}
{/* Focus helper button */}
<button
aria-label={t("viewExtension.focus")}
className="absolute top-2 right-12 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
>
<Focus className="size-4"/>
</button>
<div
className="w-full h-full flex items-center justify-center"
onMouseDownCapture={() => {
iframeRef.current?.focus();
}}
onPointerDown={() => {
iframeRef.current?.focus();
}}
onClickCapture={() => {
iframeRef.current?.focus();
}}
>
<iframe
ref={iframeRef}
src={fileUrl}
className="border-0"
style={{
width: `${baseWidth}px`,
height: `${baseHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "center center",
outline: "none",
}}
allow="fullscreen; pointer-lock; gamepad"
allowFullScreen
tabIndex={-1}
onLoad={(event) => {
event.currentTarget.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
/>
</div>
</div>
);
};

View File

@@ -110,9 +110,13 @@ function SearchChat({
let collapseWindowTimer = useRef<ReturnType<typeof setTimeout>>();
const setWindowSize = useCallback(() => {
const { viewExtensionOpened } = useSearchStore.getState();
if (collapseWindowTimer.current) {
clearTimeout(collapseWindowTimer.current);
}
if (viewExtensionOpened != null) {
return;
}
const width = 680;
let height = WINDOW_CENTER_BASELINE_HEIGHT;
@@ -177,6 +181,28 @@ function SearchChat({
onFocus: debouncedSetWindowSize,
});
useEffect(() => {
const unlisten = platformAdapter.listenEvent(
"refresh-window-size",
() => {
debouncedSetWindowSize();
}
);
return () => {
unlisten
.then((fn) => {
try {
typeof fn === "function" && fn();
} catch {
// ignore
}
})
.catch(() => {
// ignore
});
};
}, [debouncedSetWindowSize]);
useEffect(() => {
dispatch({
type: "SET_SEARCH_ACTIVE",
@@ -386,7 +412,7 @@ function SearchChat({
<div
data-tauri-drag-region={isTauri}
className={clsx(
"m-auto overflow-hidden relative bg-no-repeat flex flex-col",
"m-auto overflow-hidden relative bg-no-repeat flex flex-col bg-cover",
[
isTransitioned
? "bg-bottom bg-[url('/assets/chat_bg_light.png')] dark:bg-[url('/assets/chat_bg_dark.png')]"
@@ -401,7 +427,6 @@ function SearchChat({
}
)}
style={{
backgroundSize: "auto 590px",
opacity: blurred ? blurOpacity / 100 : normalOpacity / 100,
}}
>

View File

@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount } from "ahooks";
import { ShieldCheck, Monitor, Mic, RotateCcw } from "lucide-react";
import clsx from "clsx";
import platformAdapter from "@/utils/platformAdapter";
import SettingsItem from "@/components/Settings/SettingsItem";
const Permissions = () => {
const { t } = useTranslation();
const [accessibilityAuthorized, setAccessibilityAuthorized] = useState<boolean | null>(null);
const [screenAuthorized, setScreenAuthorized] = useState<boolean | null>(null);
const [microphoneAuthorized, setMicrophoneAuthorized] = useState<boolean | null>(null);
const refresh = async () => {
const [ax, sr, mic] = await Promise.all([
platformAdapter.invokeBackend<boolean>("check_accessibility_trusted"),
platformAdapter.checkScreenRecordingPermission(),
platformAdapter.checkMicrophonePermission(),
]);
console.info("[permissions] refreshed", { accessibility: ax, screenRecording: sr, microphone: mic });
setAccessibilityAuthorized(ax);
setScreenAuthorized(sr);
setMicrophoneAuthorized(mic);
};
useMount(refresh);
const openAccessibilitySettings = async () => {
const window = await platformAdapter.getCurrentWebviewWindow();
await window.setAlwaysOnTop(false);
console.info("[permissions] open accessibility settings");
await platformAdapter.invokeBackend("open_accessibility_settings");
await refresh();
};
const requestScreenRecording = async () => {
const window = await platformAdapter.getCurrentWebviewWindow();
await window.setAlwaysOnTop(false);
console.info("[permissions] request screen recording");
await platformAdapter.requestScreenRecordingPermission();
await platformAdapter.invokeBackend("open_screen_recording_settings");
await refresh();
};
const requestMicrophone = async () => {
const window = await platformAdapter.getCurrentWebviewWindow();
await window.setAlwaysOnTop(false);
console.info("[permissions] request microphone");
await platformAdapter.requestMicrophonePermission();
await platformAdapter.invokeBackend("open_microphone_settings");
await refresh();
};
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = async () => {
if (refreshing) return;
setRefreshing(true);
try {
await refresh();
} finally {
setRefreshing(false);
}
};
useEffect(() => {
const unlisten1 = platformAdapter.listenEvent("selection-permission-required", async () => {
console.info("[permissions] selection-permission-required received");
await refresh();
});
const unlisten2 = platformAdapter.listenEvent("selection-permission-info", async (evt: any) => {
console.info("[permissions] selection-permission-info", evt?.payload);
await refresh();
});
return () => {
unlisten1.then((fn) => fn());
unlisten2.then((fn) => fn());
};
}, []);
return (
<>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("settings.advanced.permissions.title")}
</h2>
<div className="space-y-6">
<SettingsItem
icon={ShieldCheck}
title={t("settings.advanced.permissions.accessibility.title")}
description={t("settings.advanced.permissions.accessibility.description")}
>
<div className="flex items-center gap-3">
{accessibilityAuthorized ? (
<span className="text-sm font-medium text-green-600 dark:text-green-500">
{t("settings.common.status.authorized")}
</span>
) : (
<span className="text-sm font-medium text-red-600 dark:text-red-500">
{t("settings.common.status.notAuthorized")}
</span>
)}
<button
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"
onClick={openAccessibilitySettings}
>
{t("settings.common.actions.openNow")}
</button>
<button
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100",
{ "opacity-70 cursor-not-allowed": refreshing }
)}
onClick={handleRefresh}
title={t("settings.common.actions.refresh")}
>
<RotateCcw
className={clsx("size-4", {
"animate-spin": refreshing,
})}
/>
</button>
</div>
</SettingsItem>
<SettingsItem
icon={Monitor}
title={t("settings.advanced.permissions.screenRecording.title")}
description={t("settings.advanced.permissions.screenRecording.description")}
>
<div className="flex items-center gap-3">
{screenAuthorized ? (
<span className="text-sm font-medium text-green-600 dark:text-green-500">
{t("settings.common.status.authorized")}
</span>
) : (
<span className="text-sm font-medium text-red-600 dark:text-red-500">
{t("settings.common.status.notAuthorized")}
</span>
)}
<button
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"
onClick={requestScreenRecording}
>
{t("settings.common.actions.openNow")}
</button>
<button
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100",
{ "opacity-70 cursor-not-allowed": refreshing }
)}
onClick={handleRefresh}
title={t("settings.common.actions.refresh")}
>
<RotateCcw
className={clsx("size-4", {
"animate-spin": refreshing,
})}
/>
</button>
</div>
</SettingsItem>
<SettingsItem
icon={Mic}
title={t("settings.advanced.permissions.microphone.title")}
description={t("settings.advanced.permissions.microphone.description")}
>
<div className="flex items-center gap-3">
{microphoneAuthorized ? (
<span className="text-sm font-medium text-green-600 dark:text-green-500">
{t("settings.common.status.authorized")}
</span>
) : (
<span className="text-sm font-medium text-red-600 dark:text-red-500">
{t("settings.common.status.notAuthorized")}
</span>
)}
<button
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm"
onClick={requestMicrophone}
>
{t("settings.common.actions.openNow")}
</button>
<button
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100",
{ "opacity-70 cursor-not-allowed": refreshing }
)}
onClick={handleRefresh}
title={t("settings.common.actions.refresh")}
>
<RotateCcw
className={clsx("size-4", {
"animate-spin": refreshing,
})}
/>
</button>
</div>
</SettingsItem>
</div>
</>
);
};
export default Permissions;

View File

@@ -11,6 +11,13 @@ import {
} from "lucide-react";
import { useMount } from "ahooks";
import { isNil } from "lodash-es";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import Shortcuts from "./components/Shortcuts";
import SettingsItem from "../SettingsItem";
@@ -23,13 +30,8 @@ import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
import SelectionSettings from "./components/Selection";
import { isMac } from "@/utils/platform";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import Permissions from "./components/Permissions";
const Advanced = () => {
const { t } = useTranslation();
@@ -196,6 +198,8 @@ const Advanced = () => {
})}
</div>
{isMac && <Permissions />}
{isMac && <SelectionSettings />}
<Shortcuts />

View File

@@ -75,6 +75,10 @@ export interface ViewExtensionUISettings {
search_bar: boolean;
filter_bar: boolean;
footer: boolean;
width: number | null;
height: number | null;
resizable: boolean;
detachable: boolean;
}
export interface Extension {

View File

@@ -14,19 +14,19 @@ export default function SettingsItem({
children,
}: SettingsItemProps) {
return (
<div className="flex items-center justify-between gap-6">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-between gap-6 min-w-0">
<div className="flex items-center space-x-3 min-w-0">
<Icon className="h-5 min-w-5 text-gray-400 dark:text-gray-500" />
<div>
<div className="max-w-[680px] min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
<p className="text-sm text-gray-500 dark:text-gray-400 whitespace-normal break-words">
{description}
</p>
</div>
</div>
{children}
<div className="flex-shrink-0">{children}</div>
</div>
);
}

View File

@@ -11,6 +11,9 @@ const useEscape = () => {
const setVisibleContextMenu = useSearchStore((state) => {
return state.setVisibleContextMenu;
});
const viewExtensionOpened = useSearchStore((state) => {
return state.viewExtensionOpened;
});
useKeyPress("esc", (event) => {
event.preventDefault();
@@ -33,6 +36,9 @@ const useEscape = () => {
return closeHistoryPanel();
}
if (viewExtensionOpened != null) {
return;
}
platformAdapter.hideWindow();
});
};

View File

@@ -1,26 +0,0 @@
import { useMount } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { useSelectionStore } from "@/stores/selectionStore";
export default function useSelectionEnabled() {
useMount(async () => {
try {
const enabled = await platformAdapter.invokeBackend<boolean>("get_selection_enabled");
useSelectionStore.getState().setSelectionEnabled(!!enabled);
} catch (e) {
console.error("get_selection_enabled failed:", e);
}
const unlisten = await platformAdapter.listenEvent(
"selection-enabled",
({ payload }: any) => {
useSelectionStore.getState().setSelectionEnabled(!!payload?.enabled);
}
);
return () => {
unlisten && unlisten();
};
});
}

View File

@@ -18,7 +18,7 @@ export const useTray = () => {
const showCocoShortcuts = useAppStore((state) => state.showCocoShortcuts);
const selectionEnabled = useSelectionStore((state) => state.selectionEnabled);
// const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
const setSelectionEnabled = useSelectionStore((state) => state.setSelectionEnabled);
useUpdateEffect(() => {
if (showCocoShortcuts.length === 0) return;
@@ -65,18 +65,18 @@ export const useTray = () => {
itemPromises.push(PredefinedMenuItem.new({ item: "Separator" }));
// if (isMac) {
// itemPromises.push(
// MenuItem.new({
// text: selectionEnabled
// ? t("tray.selectionDisable")
// : t("tray.selectionEnable"),
// action: async () => {
// setSelectionEnabled(!selectionEnabled);
// },
// })
// );
// }
if (isMac) {
itemPromises.push(
MenuItem.new({
text: selectionEnabled
? t("tray.selectionDisable")
: t("tray.selectionEnable"),
action: async () => {
setSelectionEnabled(!selectionEnabled);
},
})
);
}
itemPromises.push(
MenuItem.new({

View File

@@ -187,6 +187,21 @@
"description": "Get early access to new features. May be unstable."
}
},
"permissions": {
"title": "Permissions",
"accessibility": {
"title": "Accessibility",
"description": "Required to read selected text in the foreground app. Grant in System Settings → Privacy & Security → Accessibility."
},
"screenRecording": {
"title": "Screen Recording",
"description": "Required for window/screen screenshots and sharing. Grant in System Settings → Privacy & Security → Screen Recording."
},
"microphone": {
"title": "Microphone",
"description": "Required for voice input and recording. Grant in System Settings → Privacy & Security → Microphone."
}
},
"other": {
"title": "Other Settings",
"connectionTimeout": {
@@ -229,6 +244,16 @@
"extensionsContent": "Extensions settings content",
"advancedContent": "Advanced Settings content"
},
"common": {
"status": {
"authorized": "Authorized",
"notAuthorized": "Not Authorized"
},
"actions": {
"openNow": "Open Settings",
"refresh": "Refresh"
}
},
"extensions": {
"title": "Extensions",
"list": {
@@ -626,9 +651,16 @@
},
"deleteDialog": {
"title": "Uninstall",
"description": "This will remove all the data and commands associated with this extension."
"description": "This will delete all data and commands related to the extension."
}
},
"viewExtension": {
"fullscreen": {
"enter": "Enter Full Screen",
"exit": "Exit Full Screen"
},
"focus": "Focus"
},
"deleteDialog": {
"button": {
"cancel": "Cancel",

View File

@@ -187,6 +187,21 @@
"description": "抢先体验新功能,可能不稳定。"
}
},
"permissions": {
"title": "权限设置",
"accessibility": {
"title": "辅助功能Accessibility",
"description": "用于读取前台应用的选中文本,需在「隐私与安全 → 辅助功能」中授权。"
},
"screenRecording": {
"title": "屏幕录制",
"description": "用于窗口/屏幕截图与共享,需要在「隐私与安全 → 屏幕录制」中授权。"
},
"microphone": {
"title": "麦克风",
"description": "用于语音输入与录音功能,需要在「隐私与安全 → 麦克风」中授权。"
}
},
"other": {
"title": "其它设置",
"connectionTimeout": {
@@ -229,6 +244,16 @@
"extensionsContent": "扩展设置内容",
"advancedContent": "高级设置内容"
},
"common": {
"status": {
"authorized": "已授权",
"notAuthorized": "未授权"
},
"actions": {
"openNow": "去授权",
"refresh": "刷新状态"
}
},
"extensions": {
"title": "扩展",
"list": {
@@ -628,6 +653,13 @@
"description": "这将删除与该扩展相关的所有数据和命令。"
}
},
"viewExtension": {
"fullscreen": {
"enter": "进入全屏",
"exit": "退出全屏"
},
"focus": "聚焦"
},
"deleteDialog": {
"button": {
"cancel": "取消",

View File

@@ -18,7 +18,7 @@ import { useExtensionsStore } from "@/stores/extensionsStore";
import { useSelectionStore, startSelectionStorePersistence } from "@/stores/selectionStore";
import { useServers } from "@/hooks/useServers";
import { useDeepLinkManager } from "@/hooks/useDeepLinkManager";
// import { useSelectionWindow } from "@/hooks/useSelectionWindow";
import { useSelectionWindow } from "@/hooks/useSelectionWindow";
export default function LayoutOutlet() {
const location = useLocation();
@@ -128,7 +128,7 @@ export default function LayoutOutlet() {
});
// --- Selection window ---
// useSelectionWindow();
useSelectionWindow();
return (
<>

View File

@@ -33,7 +33,7 @@ export const useSelectionStore = create<SelectionStore>((set) => ({
setIconsOnly: (iconsOnly) => set({ iconsOnly }),
toolbarConfig: [],
setToolbarConfig: (toolbarConfig) => set({ toolbarConfig }),
selectionEnabled: false,
selectionEnabled: true,
setSelectionEnabled: (selectionEnabled) => set({ selectionEnabled }),
}));

View File

@@ -12,6 +12,7 @@ import { ViewExtensionOpened } from "@/stores/searchStore";
export interface EventPayloads {
"theme-changed": string;
"tauri://focus": void;
"refresh-window-size": void;
"endpoint-changed": {
endpoint: string;
endpoint_http: string;
@@ -57,6 +58,14 @@ export interface EventPayloads {
"selection-detected": string;
"selection-enabled": boolean;
"change-selection-store": any;
"selection-permission-required": boolean;
"selection-permission-info": {
bundle_id: string;
exe_path: string;
in_applications: boolean;
is_dmg: boolean;
is_dev_guess: boolean;
};
}
// Window operation interface

View File

@@ -258,7 +258,9 @@ export const navigateBack = () => {
}
if (viewExtensionOpened) {
return setViewExtensionOpened(void 0);
setViewExtensionOpened(void 0);
platformAdapter.emitEvent("refresh-window-size");
return;
}
setSourceData(void 0);

View File

@@ -16,8 +16,16 @@ import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from ".";
import { useAppStore } from "@/stores/appStore";
import { unrequitable } from "@/utils";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { Theme } from "@tauri-apps/api/window";
import {
getCurrentWebviewWindow,
WebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import {
cursorPosition,
Monitor,
monitorFromPoint,
Theme,
} from "@tauri-apps/api/window";
export interface TauriPlatformAdapter extends BasePlatformAdapter {
openFileDialog: (
@@ -30,13 +38,65 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
getWindowTheme: () => Promise<Theme | null>;
setWindowTheme: (theme: Theme | null) => Promise<void>;
getAllWindows: () => Promise<WebviewWindow[]>;
setWindowResizable: (resizable: boolean) => Promise<void>;
isWindowResizable: () => Promise<boolean>;
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
isWindowMaximized: () => Promise<boolean>;
setWindowMaximized: (enable: boolean) => Promise<void>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
setWindowPosition: (x: number, y: number) => Promise<void>;
centerWindow: () => Promise<void>;
getMonitorFromCursor: () => Promise<Monitor | null>;
centerOnCurrentMonitor: () => Promise<unknown>;
}
// Create Tauri adapter functions
export const createTauriAdapter = (): TauriPlatformAdapter => {
return {
async setWindowSize(width, height) {
return windowWrapper.setSize(width, height);
return windowWrapper.setLogicalSize(width, height);
},
async getWindowSize() {
return windowWrapper.getLogicalSize();
},
async setWindowResizable(resizable) {
return windowWrapper.setResizable(resizable);
},
async isWindowResizable() {
return windowWrapper.isResizable();
},
async setWindowFullscreen(enable) {
return windowWrapper.setFullscreen(enable);
},
async isWindowMaximized() {
return windowWrapper.isMaximized();
},
async setWindowMaximized(enable) {
return windowWrapper.setMaximized(enable);
},
async getWindowPosition() {
return windowWrapper.getLogicalPosition();
},
async setWindowPosition(x, y) {
return windowWrapper.setLogicalPosition(x, y);
},
async centerWindow() {
return windowWrapper.center();
},
async getMonitorFromCursor() {
const appWindow = getCurrentWebviewWindow();
const factor = await appWindow.scaleFactor();
const point = await cursorPosition();
const { x, y } = point.toLogical(factor);
return monitorFromPoint(x, y);
},
async centerOnCurrentMonitor() {
return windowWrapper.centerOnMonitor();
},
async hideWindow() {

View File

@@ -12,6 +12,14 @@ export interface WebPlatformAdapter extends BasePlatformAdapter {
getWindowTheme: () => Promise<string>;
setWindowTheme: (theme: string | null) => Promise<void>;
getAllWindows: () => Promise<any[]>;
setWindowResizable: (resizable: boolean) => Promise<void>;
isWindowResizable: () => Promise<boolean>;
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
getMonitorFromCursor: () => Promise<any>;
centerOnCurrentMonitor: () => Promise<void>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
setWindowPosition: (x: number, y: number) => Promise<void>;
}
// Create Web adapter functions
@@ -35,6 +43,46 @@ export const createWebAdapter = (): WebPlatformAdapter => {
console.log(`Web mode simulated window resize: ${width}x${height}`);
// No actual operation needed in web environment
},
async getWindowSize() {
return { width: window.innerWidth, height: window.innerHeight };
},
async setWindowResizable(resizable) {
console.log("Web mode simulated set window resizable:", resizable);
},
async isWindowResizable() {
return true;
},
async setWindowFullscreen(enable) {
console.log("Web mode simulated fullscreen:", enable);
},
async getMonitorFromCursor() {
return {
size: {
toLogical: (factor: number) => ({
width: window.innerWidth / factor,
height: window.innerHeight / factor,
}),
},
position: {
toLogical: (factor: number) => ({
x: window.screenX / factor,
y: window.screenY / factor,
}),
},
};
},
async centerOnCurrentMonitor() {
// Not applicable in web mode
return;
},
async getWindowPosition() {
return { x: window.screenX, y: window.screenY };
},
async setWindowPosition(x, y) {
console.log(`Web mode simulated set window position: ${x}, ${y}`);
},
async hideWindow() {
console.log("Web mode simulated window hide");
@@ -277,4 +325,4 @@ export const createWebAdapter = (): WebPlatformAdapter => {
return Promise.resolve();
},
};
};
};

View File

@@ -1,5 +1,6 @@
import * as commands from "@/commands";
import { WINDOW_CENTER_BASELINE_HEIGHT } from "@/constants";
import platformAdapter from "../platformAdapter";
// Window operations
export const windowWrapper = {
@@ -10,7 +11,7 @@ export const windowWrapper = {
return getCurrentWebviewWindow();
},
async setSize(width: number, height: number) {
async setLogicalSize(width: number, height: number) {
const { LogicalSize } = await import("@tauri-apps/api/dpi");
const window = await this.getCurrentWebviewWindow();
if (window) {
@@ -20,6 +21,95 @@ export const windowWrapper = {
}
}
},
async getLogicalSize() {
const window = await this.getCurrentWebviewWindow();
if (window) {
const size = await window.innerSize();
const scale = await window.scaleFactor();
return {
width: Math.round(size.width / scale),
height: Math.round(size.height / scale),
};
}
return { width: 0, height: 0 };
},
async setResizable(resizable: boolean) {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.setResizable(resizable);
}
},
async isResizable() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.isResizable();
}
return false;
},
async setFullscreen(enable: boolean) {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
return win.setFullscreen(enable);
},
async center() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.center();
}
},
async setLogicalPosition(x: number, y: number) {
const { LogicalPosition } = await import("@tauri-apps/api/dpi");
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.setPosition(new LogicalPosition(x, y));
}
},
async getLogicalPosition() {
const window = await this.getCurrentWebviewWindow();
if (window) {
const pos = await window.outerPosition();
const scale = await window.scaleFactor();
return { x: Math.round(pos.x / scale), y: Math.round(pos.y / scale) };
}
return { x: 0, y: 0 };
},
async centerOnMonitor() {
const { PhysicalPosition } = await import("@tauri-apps/api/dpi");
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await this.getCurrentWebviewWindow();
const { x: monitorX, y: monitorY } = monitor.position;
const { width: monitorWidth, height: monitorHeight } = monitor.size;
const windowSize = await window.innerSize();
const x = monitorX + (monitorWidth - windowSize.width) / 2;
const y = monitorY + (monitorHeight - windowSize.height) / 2;
return window.setPosition(new PhysicalPosition(x, y));
},
async isMaximized() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.isMaximized();
}
return false;
},
async setMaximized(enable: boolean) {
const window = await this.getCurrentWebviewWindow();
if (window) {
if (enable) {
return window.maximize();
} else {
return window.unmaximize();
}
}
},
};
// Event handling