Skip to content
Merged
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
88 changes: 53 additions & 35 deletions apps/desktop/Sources/SkimDesktopApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct ContentView: View {
@State private var channelBusyMessage: String?
@State private var exhaustedChannels: Set<String> = []
@State private var transcribingPostID: DashboardPost.ID?
@State private var transcribeError: String?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

현재 transcribeError는 단일 글로벌 @State 변수로 선언되어 있습니다. 이 경우 특정 포스트의 전사가 실패한 후 다른 포스트를 선택하더라도 이전 포스트의 에러 메시지가 상단바에 계속 표시되는 UX 버그가 발생합니다.

transcribeError[DashboardPost.ID: String] 딕셔너리 형태로 변경하여 에러 메시지를 각 포스트별로 격리하는 것을 권장합니다.

Suggested change
@State private var transcribeError: String?
@State private var transcribeError: [DashboardPost.ID: String] = [:]

@State private var credentialForm = CredentialForm()
@State private var credentialNotice: Notice?
@State private var pendingDeleteCredential: PlatformCredential?
Expand Down Expand Up @@ -406,45 +407,54 @@ struct ContentView: View {
}

private func readerTopBar(_ post: DashboardPost) -> some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
platformBadge(post.platform)
Text(post.source ?? post.author)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(post.displayTitle)
.font(.title2.weight(.semibold))
.lineSpacing(2)
.lineLimit(2)
.textSelection(.enabled)
}
Spacer(minLength: 16)
HStack(spacing: 10) {
if post.platform == "youtube", (post.contentMarkdown ?? "").isEmpty {
Button {
transcribe(post)
} label: {
Label(
transcribeButtonTitle(post),
systemImage: transcribingPostID == post.id ? "hourglass" : "captions.bubble"
)
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
platformBadge(post.platform)
Text(post.source ?? post.author)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
.buttonStyle(.bordered)
.disabled(transcribingPostID != nil)
Text(post.displayTitle)
.font(.title2.weight(.semibold))
.lineSpacing(2)
.lineLimit(2)
.textSelection(.enabled)
}
readerModeToggle
if let url = post.url {
Button {
NSWorkspace.shared.open(url)
} label: {
Label("열기", systemImage: "arrow.up.right")
Spacer(minLength: 16)
HStack(spacing: 10) {
if post.platform == "youtube", (post.contentMarkdown ?? "").isEmpty {
Button {
transcribe(post)
} label: {
Label(
transcribeButtonTitle(post),
systemImage: transcribingPostID == post.id ? "hourglass" : "captions.bubble"
)
}
.buttonStyle(.bordered)
.disabled(transcribingPostID != nil)
}
readerModeToggle
if let url = post.url {
Button {
NSWorkspace.shared.open(url)
} label: {
Label("열기", systemImage: "arrow.up.right")
}
.buttonStyle(.bordered)
}
.buttonStyle(.bordered)
}
}
if let transcribeError {
Text("전사 실패: \(transcribeError)")
.font(.caption)
.foregroundStyle(Color.red)
.textSelection(.enabled)
.lineLimit(3)
}
Comment on lines +451 to +457

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

transcribeError가 딕셔너리로 변경됨에 따라, 현재 선택된 포스트(post.id)에 해당하는 에러 메시지만 표시하도록 수정합니다.

Suggested change
if let transcribeError {
Text("전사 실패: \(transcribeError)")
.font(.caption)
.foregroundStyle(Color.red)
.textSelection(.enabled)
.lineLimit(3)
}
if let error = transcribeError[post.id] {
Text("전사 실패: \(error)")
.font(.caption)
.foregroundStyle(Color.red)
.textSelection(.enabled)
.lineLimit(3)
}

}
.padding(.horizontal, 30)
.padding(.vertical, 18)
Expand Down Expand Up @@ -1258,13 +1268,14 @@ struct ContentView: View {
return
}
transcribingPostID = post.id
transcribeError = nil
Task { @MainActor in
do {
_ = try await runSkim(["youtube-transcribe", url.absoluteString])
reloadChannelPosts()
loadDashboard()
} catch {
sourceMessage = Notice(text: localizedError(error), isError: true)
transcribeError = localizedError(error)
}
Comment on lines +1271 to 1279

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

전사 시작 시 해당 포스트의 에러를 초기화하고, 실패 시 해당 포스트 ID를 키로 하여 에러 메시지를 저장하도록 수정합니다.

Suggested change
transcribeError = nil
Task { @MainActor in
do {
_ = try await runSkim(["youtube-transcribe", url.absoluteString])
reloadChannelPosts()
loadDashboard()
} catch {
sourceMessage = Notice(text: localizedError(error), isError: true)
transcribeError = localizedError(error)
}
transcribeError[post.id] = nil
Task { @MainActor in
do {
_ = try await runSkim(["youtube-transcribe", url.absoluteString])
reloadChannelPosts()
loadDashboard()
} catch {
transcribeError[post.id] = localizedError(error)
}

transcribingPostID = nil
}
Expand All @@ -1285,6 +1296,13 @@ struct ContentView: View {
process.executableURL = URL(fileURLWithPath: uv)
process.arguments = ["run", "skim"] + arguments
process.currentDirectoryURL = workspace
// Finder/LaunchServices로 띄운 앱은 셸 프로필(PATH)을 물려받지 않아
// yt-dlp/ffmpeg 같은 ~/.local/bin, Homebrew 도구를 못 찾는다.
var environment = ProcessInfo.processInfo.environment
let extraPaths = ["/opt/homebrew/bin", "/usr/local/bin", "\(NSHomeDirectory())/.local/bin"]
let existingPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
environment["PATH"] = (extraPaths + [existingPath]).joined(separator: ":")
process.environment = environment
Comment on lines +1301 to +1305

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

여기에는 두 가지 중요한 개선 사항이 있습니다:

  1. 빈 PATH 컴포넌트 취약점 (CWE-426): 만약 environment["PATH"]가 빈 문자열("")인 경우, existingPath""가 되어 최종 PATH에 빈 컴포넌트(예: 끝에 붙는 콜론 :)가 포함됩니다. Unix 계열 시스템에서 PATH 내의 빈 컴포넌트는 현재 작업 디렉토리(.)를 의미하므로, 의도치 않게 현재 디렉토리의 바이너리가 실행될 수 있는 보안 취약점이 존재합니다. 따라서 빈 문자열을 필터링해야 합니다.
  2. 상대 경로 실행 실패 방지: 만약 uv가 지정된 후보 경로에 존재하지 않아 "uv"로 폴백되는 경우, ProcessexecutableURL에 절대 경로만 허용하므로 process.run() 실행 시 즉시 실패하게 됩니다. 이를 방지하기 위해 /usr/bin/env를 사용하여 새로 구성된 PATH에서 uv를 동적으로 찾아서 실행하도록 개선합니다.
Suggested change
var environment = ProcessInfo.processInfo.environment
let extraPaths = ["/opt/homebrew/bin", "/usr/local/bin", "\(NSHomeDirectory())/.local/bin"]
let existingPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
environment["PATH"] = (extraPaths + [existingPath]).joined(separator: ":")
process.environment = environment
var environment = ProcessInfo.processInfo.environment
let extraPaths = ["/opt/homebrew/bin", "/usr/local/bin", "\(NSHomeDirectory())/.local/bin"]
let existingPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
environment["PATH"] = (extraPaths + [existingPath]).filter { !$0.isEmpty }.joined(separator: ":")
process.environment = environment
if uv == "uv" {
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["uv", "run", "skim"] + arguments
}

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
Expand Down
Loading