Skip to content

Commit 287a8a2

Browse files
authored
Merge pull request #251 from OpenAgentPlatform/linux
feat: support tauri for linux
2 parents b864550 + d4b1ebb commit 287a8a2

File tree

5 files changed

+210
-9
lines changed

5 files changed

+210
-9
lines changed

.github/workflows/release.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ jobs:
1818
- os: macos-latest
1919
platform: darwin
2020
arch: arm64
21-
- os: ubuntu-latest
22-
platform: linux
23-
arch: x64
21+
# - os: ubuntu-latest
22+
# platform: linux
23+
# arch: x64
2424
runs-on: ${{ matrix.os }}
2525

2626
steps:
@@ -157,8 +157,8 @@ jobs:
157157
fail-fast: false
158158
matrix:
159159
include:
160-
# - platform: 'ubuntu-22.04'
161-
# args: ''
160+
- platform: 'ubuntu-22.04'
161+
args: ''
162162
- platform: 'windows-latest'
163163
args: ''
164164
# - platform: 'macos-latest' # for Arm based macs (M1 and above).

src-tauri/src/command/mod.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::{borrow::Cow, io::Cursor};
22

3-
use image::ImageReader;
3+
use image::{DynamicImage, ImageBuffer, ImageReader};
44
use tauri::Emitter;
55
use tauri_plugin_clipboard_manager::ClipboardExt;
66

@@ -99,3 +99,23 @@ pub async fn download_image(src: String, dst: String) -> Result<(), String> {
9999

100100
Ok(())
101101
}
102+
103+
#[tauri::command]
104+
pub async fn save_clipboard_image_to_cache(app_handle: tauri::AppHandle) -> Result<String, String> {
105+
let image = app_handle.clipboard().read_image().map_err(|e| e.to_string())?;
106+
let image_bytes = image.rgba();
107+
let width = image.width();
108+
let height = image.height();
109+
110+
let image_path = crate::shared::PROJECT_DIRS.cache.join("clipboard.png");
111+
let raw_image = DynamicImage::ImageRgba8(ImageBuffer::from_raw(width, height, image_bytes.to_vec()).unwrap());
112+
raw_image.save_with_format(&image_path, image::ImageFormat::Png).map_err(|e| e.to_string())?;
113+
114+
#[cfg(not(target_os = "windows"))]
115+
let dist = "asset://localhost/";
116+
117+
#[cfg(target_os = "windows")]
118+
let dist = "http://asset.localhost/";
119+
120+
Ok(format!("{}{}", dist, image_path.to_string_lossy()))
121+
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ pub fn run() {
283283
command::start_recv_download_dependency_log,
284284
command::copy_image,
285285
command::download_image,
286+
command::save_clipboard_image_to_cache,
286287
// llm
287288
command::llm::llm_openai_model_list,
288289
command::llm::llm_openai_compatible_model_list,

src/components/ChatInput.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import { useNavigate } from "react-router-dom"
1414
import { showToastAtom } from "../atoms/toastState"
1515
import { getTermFromModelConfig, queryGroup, queryModel, updateGroup, updateModel } from "../helper/model"
1616
import { modelSettingsAtom } from "../atoms/modelState"
17-
import { fileToBase64 } from "../util"
17+
import { fileToBase64, getFileFromImageUrl } from "../util"
1818
import { isLoggedInOAPAtom, isOAPUsageLimitAtom, oapUserAtom } from "../atoms/oapState"
1919
import Button from "./Button"
20+
import { invokeIPC, isTauri } from "../ipc"
2021

2122
interface Props {
2223
page: "welcome" | "chat"
@@ -158,13 +159,22 @@ const ChatInput: React.FC<Props> = ({ page, onSendMessage, disabled, onAbort })
158159
if (document.activeElement !== textareaRef.current)
159160
return
160161

162+
const handlePasteInTauri = async () => {
163+
if (!isTauri)
164+
return
165+
166+
const uri = await invokeIPC("save_clipboard_image_to_cache")
167+
const file = await getFileFromImageUrl(uri)
168+
handleFiles([file])
169+
}
170+
161171
const items = e.clipboardData?.items
162172
if (!items)
163-
return
173+
return handlePasteInTauri()
164174

165175
const imageItems = Array.from(items).filter(item => item.type.startsWith("image/"))
166176
if (imageItems.length === 0)
167-
return
177+
return items.length == 0 ? handlePasteInTauri() : null
168178

169179
if (imageItems.length > 0) {
170180
e.preventDefault()

src/util.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,174 @@ export function fileToBase64(file: File): Promise<string> {
2525
reader.onerror = reject
2626
reader.readAsDataURL(file)
2727
})
28+
}
29+
30+
/**
31+
* Create File object from img DOM element using Canvas API
32+
*/
33+
export async function getFileFromImageUrl(
34+
url: string,
35+
filename?: string,
36+
quality?: number
37+
): Promise<File> {
38+
return new Promise((resolve, reject) => {
39+
const imgElement = document.createElement("img")
40+
imgElement.src = url
41+
42+
// Create canvas element
43+
const canvas = document.createElement("canvas")
44+
const ctx = canvas.getContext("2d")
45+
46+
if (!ctx) {
47+
reject(new Error("Cannot get 2D context"))
48+
return
49+
}
50+
51+
// Check if image source is cross-origin
52+
const isCrossOrigin = (src: string): boolean => {
53+
try {
54+
const imgUrl = new URL(src, window.location.href)
55+
const currentUrl = new URL(window.location.href)
56+
return imgUrl.origin !== currentUrl.origin
57+
} catch {
58+
return false
59+
}
60+
}
61+
62+
// Create a new image element to ensure proper CORS handling
63+
const createCORSImage = (): Promise<HTMLImageElement> => {
64+
return new Promise((resolveImg, rejectImg) => {
65+
const newImg = new Image()
66+
67+
// Set crossOrigin before setting src for cross-origin images
68+
if (isCrossOrigin(imgElement.src)) {
69+
newImg.crossOrigin = "anonymous"
70+
}
71+
72+
newImg.onload = () => resolveImg(newImg)
73+
newImg.onerror = () => rejectImg(new Error("Failed to load image with CORS"))
74+
75+
// Set src after crossOrigin
76+
newImg.src = imgElement.src
77+
})
78+
}
79+
80+
// Process image when ready
81+
const processImage = async () => {
82+
try {
83+
let imageToUse = imgElement
84+
85+
// For cross-origin images, create a new image with proper CORS
86+
if (isCrossOrigin(imgElement.src)) {
87+
try {
88+
imageToUse = await createCORSImage()
89+
} catch (corsError) {
90+
// If CORS fails, try alternative methods
91+
console.warn("CORS image loading failed, trying alternative methods:", corsError)
92+
// Fall back to fetch method for cross-origin images
93+
return await getFileFromImageUsingFetch(imgElement.src, filename)
94+
}
95+
}
96+
97+
// Set canvas dimensions to match image natural size
98+
canvas.width = imageToUse.naturalWidth
99+
canvas.height = imageToUse.naturalHeight
100+
101+
// Draw image onto canvas
102+
ctx.drawImage(imageToUse, 0, 0)
103+
104+
// Convert canvas to blob
105+
canvas.toBlob((blob) => {
106+
if (blob) {
107+
// Create File object from blob
108+
const file = new File(
109+
[blob],
110+
filename || "image.png",
111+
{
112+
type: blob.type,
113+
lastModified: Date.now()
114+
}
115+
)
116+
resolve(file)
117+
} else {
118+
reject(new Error("Failed to convert canvas to blob"))
119+
}
120+
}, "image/png", quality || 0.9)
121+
122+
} catch (error) {
123+
// If canvas method fails due to security, try fetch method
124+
if (error instanceof DOMException && error.name === "SecurityError") {
125+
console.warn("Canvas security error, falling back to fetch method:", error)
126+
try {
127+
const file = await getFileFromImageUsingFetch(imgElement.src, filename)
128+
resolve(file)
129+
} catch (fetchError) {
130+
const fetchErrorMessage = fetchError instanceof Error ? fetchError.message : String(fetchError)
131+
reject(new Error(`Both canvas and fetch methods failed: ${error.message}, ${fetchErrorMessage}`))
132+
}
133+
} else {
134+
reject(error)
135+
}
136+
}
137+
}
138+
139+
// Check if image is already loaded
140+
if (imgElement.complete && imgElement.naturalHeight !== 0) {
141+
processImage()
142+
} else {
143+
// Wait for image to load
144+
imgElement.onload = processImage
145+
imgElement.onerror = () => reject(new Error("Image failed to load"))
146+
}
147+
})
148+
}
149+
150+
/**
151+
* Alternative method using fetch for cross-origin images
152+
*/
153+
async function getFileFromImageUsingFetch(
154+
imageSrc: string,
155+
filename?: string
156+
): Promise<File> {
157+
try {
158+
const response = await fetch(imageSrc)
159+
160+
if (!response.ok) {
161+
throw new Error(`HTTP error! status: ${response.status}`)
162+
}
163+
164+
const blob = await response.blob()
165+
const file = new File(
166+
[blob],
167+
filename || getFilenameFromUrl(imageSrc),
168+
{
169+
type: blob.type || "image/png",
170+
lastModified: Date.now()
171+
}
172+
)
173+
174+
return file
175+
} catch (error) {
176+
const errorMessage = error instanceof Error ? error.message : String(error)
177+
throw new Error(`Fetch method failed: ${errorMessage}`)
178+
}
179+
}
180+
181+
/**
182+
* Get filename from URL
183+
*/
184+
function getFilenameFromUrl(url: string): string {
185+
try {
186+
const pathname = new URL(url).pathname
187+
const filename = pathname.split("/").pop() || "image"
188+
189+
// Add extension if missing
190+
if (!filename.includes(".")) {
191+
return filename + ".png"
192+
}
193+
194+
return filename
195+
} catch {
196+
return "image.png"
197+
}
28198
}

0 commit comments

Comments
 (0)