Skip to content

Commit 9d5cace

Browse files
Jack Coweycursoragent
andcommitted
Prepare release 0.1.18.
Store server passwords in OS secure keyring via Tauri commands, load them at connect time, and keep local snapshots free of plaintext secrets. Also route desktop link opens through a native URL opener command. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6f9e4de commit 9d5cace

7 files changed

Lines changed: 176 additions & 24 deletions

File tree

lib/external-links.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { openExternalUrl } from "./irc-native"
2+
13
// Thin wrapper so we can centralize external link behavior.
2-
// For both web and desktop (Tauri), falling back to window.open
3-
// is sufficient to open the user's default browser.
44
export async function shellOpenExternal(url: string): Promise<void> {
55
if (typeof window === "undefined") return
6+
if ("__TAURI_INTERNALS__" in window) {
7+
await openExternalUrl(url)
8+
return
9+
}
610
window.open(url, "_blank", "noopener,noreferrer")
711
}
8-
9-

lib/irc-native.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,34 @@ export async function sendNativeRaw(serverId: string, line: string) {
8282
await invoke("irc_raw", { serverId, line })
8383
}
8484

85+
export async function openExternalUrl(url: string) {
86+
if (!hasTauriInternals()) return
87+
await invoke("open_external_url", { url })
88+
}
89+
90+
export async function secureSetServerSecret(
91+
serverId: string,
92+
secretName: "password" | "saslPassword",
93+
value: string
94+
) {
95+
if (!hasTauriInternals()) return
96+
await invoke("secure_set_server_secret", { serverId, secretName, value })
97+
}
98+
99+
export async function secureGetServerSecret(
100+
serverId: string,
101+
secretName: "password" | "saslPassword"
102+
): Promise<string | null> {
103+
if (!hasTauriInternals()) return null
104+
const v = await invoke<string | null>("secure_get_server_secret", { serverId, secretName })
105+
return v ?? null
106+
}
107+
108+
export async function secureDeleteServerSecret(
109+
serverId: string,
110+
secretName: "password" | "saslPassword"
111+
) {
112+
if (!hasTauriInternals()) return
113+
await invoke("secure_delete_server_secret", { serverId, secretName })
114+
}
115+

lib/store.ts

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
partNativeIrc,
1313
sendNativePrivmsg,
1414
sendNativeRaw,
15+
secureDeleteServerSecret,
16+
secureGetServerSecret,
17+
secureSetServerSecret,
1518
} from './irc-native'
1619

1720
function hexToHSL(hex: string): string {
@@ -160,10 +163,8 @@ type ServerSnapshot = Pick<
160163
| 'nickname'
161164
| 'username'
162165
| 'realName'
163-
| 'password'
164166
| 'saslEnabled'
165167
| 'saslUsername'
166-
| 'saslPassword'
167168
| 'autoJoinChannels'
168169
| 'onJoinCommands'
169170
>
@@ -202,17 +203,33 @@ function saveServersSnapshot(servers: IRCServer[]) {
202203
nickname: s.nickname,
203204
username: s.username,
204205
realName: s.realName,
205-
password: s.password,
206206
saslEnabled: s.saslEnabled,
207207
saslUsername: s.saslUsername,
208-
saslPassword: s.saslPassword,
209208
autoJoinChannels: s.autoJoinChannels,
210209
onJoinCommands: s.onJoinCommands,
211210
}))
212211
localStorage.setItem('patchcord-servers', JSON.stringify(snapshot))
213212
} catch {}
214213
}
215214

215+
function persistServerSecrets(server: IRCServer) {
216+
if (!isLiveBuild) return
217+
218+
const pw = (server.password || '').trim()
219+
if (pw.length > 0) {
220+
void secureSetServerSecret(server.id, 'password', pw).catch(() => {})
221+
} else {
222+
void secureDeleteServerSecret(server.id, 'password').catch(() => {})
223+
}
224+
225+
const saslPw = (server.saslPassword || '').trim()
226+
if (saslPw.length > 0) {
227+
void secureSetServerSecret(server.id, 'saslPassword', saslPw).catch(() => {})
228+
} else {
229+
void secureDeleteServerSecret(server.id, 'saslPassword').catch(() => {})
230+
}
231+
}
232+
216233
interface IRCStore {
217234
servers: IRCServer[]
218235
settings: AppSettings
@@ -382,6 +399,7 @@ export const useIRCStore = create<IRCStore>((set, get) => ({
382399
activeView: state.activeView.serverId ? state.activeView : { serverId: id, channelId: '' },
383400
}
384401
})
402+
persistServerSecrets(server)
385403
get().connectServer(id)
386404
return
387405
}
@@ -470,12 +488,20 @@ export const useIRCStore = create<IRCStore>((set, get) => ({
470488
const servers = state.servers.map((s) =>
471489
s.id === serverId ? { ...s, ...data } : s
472490
)
491+
const updated = servers.find((s) => s.id === serverId)
492+
if (updated) {
493+
persistServerSecrets(updated)
494+
}
473495
saveServersSnapshot(servers)
474496
return { servers }
475497
})
476498
},
477499

478500
removeServer: (serverId) => {
501+
if (isLiveBuild) {
502+
void secureDeleteServerSecret(serverId, 'password').catch(() => {})
503+
void secureDeleteServerSecret(serverId, 'saslPassword').catch(() => {})
504+
}
479505
set((state) => {
480506
const newServers = state.servers.filter((s) => s.id !== serverId)
481507
let newView = state.activeView
@@ -509,18 +535,21 @@ export const useIRCStore = create<IRCStore>((set, get) => ({
509535
if (!server) return
510536
get().updateServerStatus(serverId, 'connecting')
511537
get().addServerMessage(serverId, `Connecting to ${server.host}:${server.port}${server.ssl ? ' (TLS)' : ''}...`)
512-
void connectNativeIrc({
513-
serverId,
514-
host: server.host,
515-
port: server.port,
516-
ssl: server.ssl,
517-
allowInvalidCerts: !!server.allowInvalidCerts,
518-
nickname: server.nickname,
519-
username: server.username,
520-
realName: server.realName,
521-
password: server.password,
522-
autoJoinChannels: server.autoJoinChannels,
523-
}).catch((err) => {
538+
void (async () => {
539+
const storedPassword = await secureGetServerSecret(serverId, 'password').catch(() => null)
540+
await connectNativeIrc({
541+
serverId,
542+
host: server.host,
543+
port: server.port,
544+
ssl: server.ssl,
545+
allowInvalidCerts: !!server.allowInvalidCerts,
546+
nickname: server.nickname,
547+
username: server.username,
548+
realName: server.realName,
549+
password: storedPassword ?? server.password,
550+
autoJoinChannels: server.autoJoinChannels,
551+
})
552+
})().catch((err) => {
524553
get().updateServerStatus(serverId, 'disconnected')
525554
get().addServerMessage(serverId, `Connection failed: ${String(err)}`)
526555
})

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "my-project",
3-
"version": "0.1.17",
3+
"version": "0.1.18",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbo",

src-tauri/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "patchcord-desktop"
3-
version = "0.1.17"
3+
version = "0.1.18"
44
description = "Patchcord desktop app"
55
authors = ["Patchcord"]
66
license = ""
@@ -27,3 +27,4 @@ tokio = { version = "1", features = ["full"] }
2727
tokio-native-tls = "0.3"
2828
native-tls = "=0.2.14"
2929
once_cell = "1"
30+
keyring = "3"

src-tauri/src/lib.rs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
mod irc;
2+
use std::process::Command;
3+
use keyring::Entry;
24

35
// Allow camelCase parameter names here so they match
46
// the keys sent from the frontend `invoke` calls.
@@ -70,6 +72,89 @@ async fn irc_raw(serverId: String, line: String) -> Result<(), String> {
7072
irc::raw(serverId, line).await
7173
}
7274

75+
#[allow(non_snake_case)]
76+
#[tauri::command]
77+
async fn open_external_url(url: String) -> Result<(), String> {
78+
if !(url.starts_with("http://") || url.starts_with("https://")) {
79+
return Err("Only http(s) URLs are allowed".to_string());
80+
}
81+
82+
#[cfg(target_os = "windows")]
83+
{
84+
Command::new("cmd")
85+
.args(["/C", "start", "", &url])
86+
.spawn()
87+
.map_err(|e| format!("Failed to open URL: {e}"))?;
88+
}
89+
90+
#[cfg(target_os = "macos")]
91+
{
92+
Command::new("open")
93+
.arg(&url)
94+
.spawn()
95+
.map_err(|e| format!("Failed to open URL: {e}"))?;
96+
}
97+
98+
#[cfg(all(unix, not(target_os = "macos")))]
99+
{
100+
Command::new("xdg-open")
101+
.arg(&url)
102+
.spawn()
103+
.map_err(|e| format!("Failed to open URL: {e}"))?;
104+
}
105+
106+
Ok(())
107+
}
108+
109+
fn secret_entry(app: &tauri::AppHandle, server_id: &str, secret_name: &str) -> Result<Entry, String> {
110+
let service = app.config().identifier.clone();
111+
let account = format!("patchcord:{server_id}:{secret_name}");
112+
Entry::new(&service, &account).map_err(|e| format!("Secure storage init failed: {e}"))
113+
}
114+
115+
#[allow(non_snake_case)]
116+
#[tauri::command]
117+
async fn secure_set_server_secret(
118+
app: tauri::AppHandle,
119+
serverId: String,
120+
secretName: String,
121+
value: String,
122+
) -> Result<(), String> {
123+
let entry = secret_entry(&app, &serverId, &secretName)?;
124+
entry
125+
.set_password(&value)
126+
.map_err(|e| format!("Secure storage write failed: {e}"))
127+
}
128+
129+
#[allow(non_snake_case)]
130+
#[tauri::command]
131+
async fn secure_get_server_secret(
132+
app: tauri::AppHandle,
133+
serverId: String,
134+
secretName: String,
135+
) -> Result<Option<String>, String> {
136+
let entry = secret_entry(&app, &serverId, &secretName)?;
137+
match entry.get_password() {
138+
Ok(v) => Ok(Some(v)),
139+
Err(keyring::Error::NoEntry) => Ok(None),
140+
Err(e) => Err(format!("Secure storage read failed: {e}")),
141+
}
142+
}
143+
144+
#[allow(non_snake_case)]
145+
#[tauri::command]
146+
async fn secure_delete_server_secret(
147+
app: tauri::AppHandle,
148+
serverId: String,
149+
secretName: String,
150+
) -> Result<(), String> {
151+
let entry = secret_entry(&app, &serverId, &secretName)?;
152+
match entry.delete_credential() {
153+
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
154+
Err(e) => Err(format!("Secure storage delete failed: {e}")),
155+
}
156+
}
157+
73158
#[cfg_attr(mobile, tauri::mobile_entry_point)]
74159
pub fn run() {
75160
tauri::Builder::default()
@@ -89,7 +174,11 @@ pub fn run() {
89174
irc_join,
90175
irc_part,
91176
irc_privmsg,
92-
irc_raw
177+
irc_raw,
178+
open_external_url,
179+
secure_set_server_secret,
180+
secure_get_server_secret,
181+
secure_delete_server_secret
93182
])
94183
.run(tauri::generate_context!())
95184
.expect("error while running tauri application");

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Patchcord",
4-
"version": "0.1.17",
4+
"version": "0.1.18",
55
"identifier": "com.patchcord.desktop",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

0 commit comments

Comments
 (0)