feat: save short cut image to remote by ssh
- add settings for ssh config - implement save remote function by ssh
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/mmmy/snapgo/internal/infrastructure/hotkey"
|
"github.com/mmmy/snapgo/internal/infrastructure/hotkey"
|
||||||
"github.com/mmmy/snapgo/internal/infrastructure/oss"
|
"github.com/mmmy/snapgo/internal/infrastructure/oss"
|
||||||
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
|
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
|
||||||
|
sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App is the struct exposed to the Wails frontend through Bind.
|
// App is the struct exposed to the Wails frontend through Bind.
|
||||||
@@ -315,6 +316,38 @@ func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error)
|
|||||||
return svc.SaveImage(a.ctx, pngBytes, dir)
|
return svc.SaveImage(a.ctx, pngBytes, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runSaveRemotePipeline uploads the captured PNG to the configured SSH host
|
||||||
|
// via SCP and copies a sharable reference (URL or "user@host:~/path") to
|
||||||
|
// the clipboard.
|
||||||
|
//
|
||||||
|
// Why a dedicated method (vs. extending CaptureAndUploadService): SSH and
|
||||||
|
// S3 have different config + clipboard semantics, so keeping them separate
|
||||||
|
// avoids leaking provider-specific branches into the OSS pipeline.
|
||||||
|
func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
|
||||||
|
a.mu.RLock()
|
||||||
|
cfg := a.cfg
|
||||||
|
a.mu.RUnlock()
|
||||||
|
|
||||||
|
if !cfg.IsSSHConfigured() {
|
||||||
|
err := fmt.Errorf("SSH host/user is not configured")
|
||||||
|
slog.Warn("save-remote rejected: ssh not configured",
|
||||||
|
"host", cfg.SSH.Host, "user", cfg.SSH.User)
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("save-remote dispatch",
|
||||||
|
"host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port,
|
||||||
|
"png_size", len(pngBytes))
|
||||||
|
|
||||||
|
svc := &application.CaptureAndSSHService{
|
||||||
|
Uploader: sshpkg.NewUploader(cfg.SSH),
|
||||||
|
Clipboard: a.clip,
|
||||||
|
Notifier: &runtimeNotifier{ctx: a.ctx},
|
||||||
|
Cfg: cfg.SSH,
|
||||||
|
}
|
||||||
|
return svc.ExecuteWithBytes(a.ctx, pngBytes)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) consumePendingCapture() (*pendingCapture, error) {
|
func (a *App) consumePendingCapture() (*pendingCapture, error) {
|
||||||
a.pendingMu.Lock()
|
a.pendingMu.Lock()
|
||||||
pc := a.pending
|
pc := a.pending
|
||||||
@@ -407,6 +440,20 @@ func (a *App) TestConnection(cfg domain.S3Config) error {
|
|||||||
return provider.TestConnection(a.ctx)
|
return provider.TestConnection(a.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSSHConnection performs a minimal SSH handshake probe against the
|
||||||
|
// supplied configuration so the SettingsView's "Test connection" button
|
||||||
|
// can give the user immediate feedback.
|
||||||
|
func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
|
||||||
|
slog.Info("RPC TestSSHConnection",
|
||||||
|
"host", cfg.Host, "user", cfg.User, "port", cfg.Port,
|
||||||
|
"strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "")
|
||||||
|
if err := sshpkg.TestConnection(a.ctx, cfg); err != nil {
|
||||||
|
slog.Error("RPC TestSSHConnection failed", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CaptureNow is the in-app trigger.
|
// CaptureNow is the in-app trigger.
|
||||||
func (a *App) CaptureNow() {
|
func (a *App) CaptureNow() {
|
||||||
go a.runInteractiveCapture()
|
go a.runInteractiveCapture()
|
||||||
@@ -604,6 +651,70 @@ func (a *App) SaveNativeRegionImageToDir(result CaptureResult, dir string) (Capt
|
|||||||
return CaptureActionResult{Completed: true, Path: path}, nil
|
return CaptureActionResult{Completed: true, Path: path}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveRegionToRemote uploads the user-selected region via SCP to the
|
||||||
|
// configured SSH host. Driven by the Wails overlay's "save remote" button.
|
||||||
|
//
|
||||||
|
// The flow mirrors ConfirmRegion (S3 upload) but routes through
|
||||||
|
// runSaveRemotePipeline instead. We dismiss the overlay first so the
|
||||||
|
// upload progress (and any error toast) shows over the regular settings UI.
|
||||||
|
func (a *App) SaveRegionToRemote(result CaptureResult) error {
|
||||||
|
pc, err := a.consumePendingCapture()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("SaveRegionToRemote: no pending capture", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
a.dismissOverlay()
|
||||||
|
}()
|
||||||
|
|
||||||
|
a.dismissOverlay()
|
||||||
|
flushFrame()
|
||||||
|
|
||||||
|
slog.Info("SaveRegionToRemote: capturing region",
|
||||||
|
"x", result.Rect.X, "y", result.Rect.Y,
|
||||||
|
"w", result.Rect.W, "h", result.Rect.H,
|
||||||
|
"annotations", len(result.Annotations))
|
||||||
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("SaveRegionToRemote: capture failed", "err", err)
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Debug("SaveRegionToRemote: capture ok", "png_bytes", len(cropped))
|
||||||
|
return a.runSaveRemotePipeline(cropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveNativeRegionToRemote is the macOS-native overlay equivalent of
|
||||||
|
// SaveRegionToRemote. The AppKit panel is already closed by the time this
|
||||||
|
// runs (see saveRemoteSelection in native_overlay_darwin.go), so we only
|
||||||
|
// have to release the dock icon afterwards.
|
||||||
|
func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
|
||||||
|
pc, err := a.consumePendingCapture()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("SaveNativeRegionToRemote: no pending capture", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
hideDockIcon()
|
||||||
|
}()
|
||||||
|
|
||||||
|
flushFrame()
|
||||||
|
slog.Info("SaveNativeRegionToRemote: capturing region",
|
||||||
|
"x", result.Rect.X, "y", result.Rect.Y,
|
||||||
|
"w", result.Rect.W, "h", result.Rect.H,
|
||||||
|
"annotations", len(result.Annotations))
|
||||||
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("SaveNativeRegionToRemote: capture failed", "err", err)
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Debug("SaveNativeRegionToRemote: capture ok", "png_bytes", len(cropped))
|
||||||
|
return a.runSaveRemotePipeline(cropped)
|
||||||
|
}
|
||||||
|
|
||||||
func parseNativeAnnotations(raw string) []application.Annotation {
|
func parseNativeAnnotations(raw string) []application.Annotation {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+55
-3
@@ -23,6 +23,7 @@ import {
|
|||||||
ConfirmRegion,
|
ConfirmRegion,
|
||||||
CopyRegionImage,
|
CopyRegionImage,
|
||||||
SaveRegionImage,
|
SaveRegionImage,
|
||||||
|
SaveRegionToRemote,
|
||||||
CancelRegion,
|
CancelRegion,
|
||||||
} from '../wailsjs/go/main/App'
|
} from '../wailsjs/go/main/App'
|
||||||
|
|
||||||
@@ -35,6 +36,20 @@ const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
|
|||||||
type Mode = 'settings' | 'overlay'
|
type Mode = 'settings' | 'overlay'
|
||||||
const mode = ref<Mode>('settings')
|
const mode = ref<Mode>('settings')
|
||||||
|
|
||||||
|
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives
|
||||||
|
// here) and the inner SettingsView (which renders the matching card) can
|
||||||
|
// share a single source of truth without an event bus.
|
||||||
|
type SettingsTab = 'general' | 's3' | 'ssh'
|
||||||
|
const activeTab = ref<SettingsTab>('general')
|
||||||
|
|
||||||
|
// Sidebar entries are declarative so adding a destination type later is
|
||||||
|
// a one-line change. The label values match the user-facing tab names.
|
||||||
|
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 's3', label: 'S3-Conf' },
|
||||||
|
{ id: 'ssh', label: 'SSH-Conf' },
|
||||||
|
]
|
||||||
|
|
||||||
interface OverlayPayload {
|
interface OverlayPayload {
|
||||||
cssWidth: number
|
cssWidth: number
|
||||||
cssHeight: number
|
cssHeight: number
|
||||||
@@ -131,6 +146,28 @@ async function onOverlaySave(rect: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onOverlaySaveRemote(rect: {
|
||||||
|
rect: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
annotations: Array<{
|
||||||
|
tool: string
|
||||||
|
color: string
|
||||||
|
points: Array<{ x: number; y: number }>
|
||||||
|
}>
|
||||||
|
}) {
|
||||||
|
mode.value = 'settings'
|
||||||
|
overlayPayload.value = null
|
||||||
|
try {
|
||||||
|
await SaveRegionToRemote(rect as any)
|
||||||
|
} catch {
|
||||||
|
/* Surfaced via upload:failure */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onOverlayCancel() {
|
async function onOverlayCancel() {
|
||||||
mode.value = 'settings'
|
mode.value = 'settings'
|
||||||
overlayPayload.value = null
|
overlayPayload.value = null
|
||||||
@@ -191,6 +228,7 @@ onUnmounted(() => {
|
|||||||
@confirm="onOverlayConfirm"
|
@confirm="onOverlayConfirm"
|
||||||
@copy="onOverlayCopy"
|
@copy="onOverlayCopy"
|
||||||
@save="onOverlaySave"
|
@save="onOverlaySave"
|
||||||
|
@save-remote="onOverlaySaveRemote"
|
||||||
@cancel="onOverlayCancel"
|
@cancel="onOverlayCancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -210,10 +248,18 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-item active"><span class="dot" /> Settings</div>
|
<div
|
||||||
|
v-for="item in sidebarItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: activeTab === item.id }"
|
||||||
|
@click="activeTab = item.id"
|
||||||
|
>
|
||||||
|
<span class="dot" /> {{ item.label }}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<SettingsView />
|
<SettingsView :tab="activeTab" />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -304,7 +350,10 @@ onUnmounted(() => {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sidebar-item:hover:not(.active) {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
background: rgba(59, 130, 246, 0.12);
|
background: rgba(59, 130, 246, 0.12);
|
||||||
@@ -341,6 +390,9 @@ onUnmounted(() => {
|
|||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
.sidebar-item:hover:not(.active) {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
background: rgba(59, 130, 246, 0.18);
|
background: rgba(59, 130, 246, 0.18);
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import cancelIcon from '../assets/icons/cancel.svg?raw'
|
import cancelIcon from '../assets/icons/cancel.svg?raw'
|
||||||
import copyIcon from '../assets/icons/copy.svg?raw'
|
import copyIcon from '../assets/icons/copy.svg?raw'
|
||||||
import saveIcon from '../assets/icons/save-local.svg?raw'
|
import saveIcon from '../assets/icons/save-local.svg?raw'
|
||||||
|
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
|
||||||
import uploadIcon from '../assets/icons/upload.svg?raw'
|
import uploadIcon from '../assets/icons/upload.svg?raw'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,6 +47,10 @@ const emit = defineEmits<{
|
|||||||
e: 'save',
|
e: 'save',
|
||||||
payload: { rect: Rect; annotations: Annotation[] }
|
payload: { rect: Rect; annotations: Annotation[] }
|
||||||
): void
|
): void
|
||||||
|
(
|
||||||
|
e: 'save-remote',
|
||||||
|
payload: { rect: Rect; annotations: Annotation[] }
|
||||||
|
): void
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -108,7 +113,7 @@ const sizeLabel = computed(() => {
|
|||||||
|
|
||||||
const rightToolbarPos = computed(() => {
|
const rightToolbarPos = computed(() => {
|
||||||
if (!rect.value) return null
|
if (!rect.value) return null
|
||||||
return placeToolbar(rect.value, 144, 40, 'right')
|
return placeToolbar(rect.value, 180, 40, 'right')
|
||||||
})
|
})
|
||||||
|
|
||||||
const leftToolbarPos = computed(() => {
|
const leftToolbarPos = computed(() => {
|
||||||
@@ -344,7 +349,11 @@ function onSave() {
|
|||||||
emitAction('save')
|
emitAction('save')
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitAction(action: 'confirm' | 'copy' | 'save') {
|
function onSaveRemote() {
|
||||||
|
emitAction('save-remote')
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
|
||||||
if (!rect.value) return
|
if (!rect.value) return
|
||||||
const payload = {
|
const payload = {
|
||||||
rect: { ...rect.value },
|
rect: { ...rect.value },
|
||||||
@@ -353,6 +362,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save') {
|
|||||||
if (action === 'confirm') emit('confirm', payload)
|
if (action === 'confirm') emit('confirm', payload)
|
||||||
if (action === 'copy') emit('copy', payload)
|
if (action === 'copy') emit('copy', payload)
|
||||||
if (action === 'save') emit('save', payload)
|
if (action === 'save') emit('save', payload)
|
||||||
|
if (action === 'save-remote') emit('save-remote', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancel() {
|
function onCancel() {
|
||||||
@@ -585,6 +595,13 @@ onUnmounted(() => {
|
|||||||
@click="onSave"
|
@click="onSave"
|
||||||
v-html="saveIcon"
|
v-html="saveIcon"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
data-tip="保存远端"
|
||||||
|
aria-label="保存远端"
|
||||||
|
@click="onSaveRemote"
|
||||||
|
v-html="saveRemoteIcon"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
class="action-btn primary"
|
class="action-btn primary"
|
||||||
data-tip="上传云端"
|
data-tip="上传云端"
|
||||||
@@ -679,7 +696,7 @@ onUnmounted(() => {
|
|||||||
width: 190px;
|
width: 190px;
|
||||||
}
|
}
|
||||||
.action-toolbar {
|
.action-toolbar {
|
||||||
width: 144px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn,
|
.icon-btn,
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/*
|
/*
|
||||||
* SettingsView — the home screen of SnapGo before the user triggers a
|
* SettingsView — the home screen of SnapGo before the user triggers a
|
||||||
* capture. It exposes:
|
* capture. The active configuration section is selected by the App-shell
|
||||||
* 1. Hotkey configuration
|
* sidebar and passed in via the `tab` prop, so this view only owns the
|
||||||
* 2. S3-compatible OSS configuration
|
* card content for the currently selected destination type:
|
||||||
* 3. Manual capture button (useful when developing or when the global
|
*
|
||||||
* hotkey conflicts with another app)
|
* • "general" — the global hotkey / capture parameters.
|
||||||
|
* • "s3" — S3-compatible object-storage credentials.
|
||||||
|
* • "ssh" — SSH/SCP destination for the "save remote" button.
|
||||||
*
|
*
|
||||||
* State flow: load() pulls config from Go on mount, save() pushes back.
|
* State flow: load() pulls config from Go on mount, save() pushes back.
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
GetConfig,
|
GetConfig,
|
||||||
SaveConfig,
|
SaveConfig,
|
||||||
TestConnection,
|
TestConnection,
|
||||||
|
TestSSHConnection,
|
||||||
CaptureNow,
|
CaptureNow,
|
||||||
} from '../../wailsjs/go/main/App'
|
} from '../../wailsjs/go/main/App'
|
||||||
|
|
||||||
|
// Tab discriminator shared with the App shell. Kept as a string union so
|
||||||
|
// the parent can pass the value without importing a type from this view.
|
||||||
|
type TabId = 'general' | 's3' | 'ssh'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tab: TabId
|
||||||
|
}>()
|
||||||
|
|
||||||
interface S3Config {
|
interface S3Config {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
region: string
|
region: string
|
||||||
@@ -28,29 +39,74 @@ interface S3Config {
|
|||||||
usePathStyle: boolean
|
usePathStyle: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SSHConfig {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
user: string
|
||||||
|
password: string
|
||||||
|
pathPrefix: string
|
||||||
|
strictHostKey: boolean
|
||||||
|
knownHostsPath: string
|
||||||
|
connectTimeoutSecs: number
|
||||||
|
}
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
hotkey: string
|
hotkey: string
|
||||||
s3: S3Config
|
s3: S3Config
|
||||||
|
ssh: SSHConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = ref<AppConfig>({
|
// `defaultConfig` keeps the initial render in sync with the Go-side
|
||||||
hotkey: 'cmd+shift+a',
|
// DefaultAppConfig so the UI never flashes empty fields before load()
|
||||||
s3: {
|
// completes. Centralising the defaults also avoids subtle drift between
|
||||||
endpoint: '',
|
// frontend and backend.
|
||||||
region: 'us-east-1',
|
function defaultConfig(): AppConfig {
|
||||||
bucket: '',
|
return {
|
||||||
accessKeyId: '',
|
hotkey: 'cmd+shift+a',
|
||||||
secretAccessKey: '',
|
s3: {
|
||||||
pathPrefix: 'snapgo/',
|
endpoint: '',
|
||||||
publicUrlBase: '',
|
region: 'us-east-1',
|
||||||
usePathStyle: true,
|
bucket: '',
|
||||||
},
|
accessKeyId: '',
|
||||||
})
|
secretAccessKey: '',
|
||||||
|
pathPrefix: 'snapgo/',
|
||||||
|
publicUrlBase: '',
|
||||||
|
usePathStyle: true,
|
||||||
|
},
|
||||||
|
ssh: {
|
||||||
|
host: '',
|
||||||
|
port: 22,
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
pathPrefix: 'snapgo/',
|
||||||
|
strictHostKey: false,
|
||||||
|
knownHostsPath: '',
|
||||||
|
connectTimeoutSecs: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = ref<AppConfig>(defaultConfig())
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testing = ref(false)
|
const testing = ref(false)
|
||||||
|
const testingSSH = ref(false)
|
||||||
const message = ref<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
const message = ref<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
||||||
|
|
||||||
|
// The "remote path" in the UI is shown as "~/<pathPrefix>" so the user
|
||||||
|
// understands that everything is rooted at the remote home directory.
|
||||||
|
// We strip leading slashes / tilde here defensively in case someone pastes
|
||||||
|
// a full path.
|
||||||
|
const sshPathDisplay = computed({
|
||||||
|
get: () => config.value.ssh.pathPrefix,
|
||||||
|
set: (raw: string) => {
|
||||||
|
let cleaned = raw.trim()
|
||||||
|
cleaned = cleaned.replace(/^~/, '')
|
||||||
|
cleaned = cleaned.replace(/^\/+/, '')
|
||||||
|
config.value.ssh.pathPrefix = cleaned
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function flash(kind: 'ok' | 'err', text: string) {
|
function flash(kind: 'ok' | 'err', text: string) {
|
||||||
message.value = { kind, text }
|
message.value = { kind, text }
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -61,7 +117,13 @@ function flash(kind: 'ok' | 'err', text: string) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
const cfg = await GetConfig()
|
const cfg = await GetConfig()
|
||||||
if (cfg) {
|
if (cfg) {
|
||||||
config.value = cfg as unknown as AppConfig
|
// Merge with defaults so a config file written before SSH support
|
||||||
|
// existed does not leave ssh fields undefined.
|
||||||
|
const merged = defaultConfig()
|
||||||
|
Object.assign(merged, cfg)
|
||||||
|
merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
|
||||||
|
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
|
||||||
|
config.value = merged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +151,18 @@ async function test() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testSSH() {
|
||||||
|
testingSSH.value = true
|
||||||
|
try {
|
||||||
|
await TestSSHConnection(config.value.ssh as any)
|
||||||
|
flash('ok', 'SSH connection OK')
|
||||||
|
} catch (err: any) {
|
||||||
|
flash('err', `SSH test failed: ${err?.message ?? err}`)
|
||||||
|
} finally {
|
||||||
|
testingSSH.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function manualCapture() {
|
async function manualCapture() {
|
||||||
await CaptureNow()
|
await CaptureNow()
|
||||||
}
|
}
|
||||||
@@ -109,7 +183,8 @@ onMounted(load)
|
|||||||
<button class="btn primary" @click="manualCapture">Capture now</button>
|
<button class="btn primary" @click="manualCapture">Capture now</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card">
|
<!-- General tab — only the shortcut configuration lives here. -->
|
||||||
|
<section v-if="props.tab === 'general'" class="card">
|
||||||
<h2>Shortcut</h2>
|
<h2>Shortcut</h2>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Global hotkey</span>
|
<span>Global hotkey</span>
|
||||||
@@ -123,9 +198,15 @@ onMounted(load)
|
|||||||
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
|
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
|
||||||
a–z or 0–9. Example: <code>cmd+shift+a</code>.
|
a–z or 0–9. Example: <code>cmd+shift+a</code>.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn primary" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<!-- S3-Conf tab — unchanged content, moved out of General. -->
|
||||||
|
<section v-if="props.tab === 's3'" class="card">
|
||||||
<h2>S3-compatible storage</h2>
|
<h2>S3-compatible storage</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -181,6 +262,83 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- SSH-Conf tab — destination for the save-remote action. -->
|
||||||
|
<section v-if="props.tab === 'ssh'" class="card">
|
||||||
|
<h2>SSH / SCP destination</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Host (IP or domain) *</span>
|
||||||
|
<input v-model="config.ssh.host" placeholder="192.168.1.10" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Port</span>
|
||||||
|
<input
|
||||||
|
v-model.number="config.ssh.port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>User *</span>
|
||||||
|
<input v-model="config.ssh.user" placeholder="ubuntu" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Password (optional)</span>
|
||||||
|
<input v-model="config.ssh.password" type="password" />
|
||||||
|
</label>
|
||||||
|
<label class="field full">
|
||||||
|
<span>Remote path (under home)</span>
|
||||||
|
<div class="prefixed-input">
|
||||||
|
<span class="input-prefix">~/</span>
|
||||||
|
<input
|
||||||
|
v-model="sshPathDisplay"
|
||||||
|
placeholder="snapgo/"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Connect timeout (sec)</span>
|
||||||
|
<input
|
||||||
|
v-model.number="config.ssh.connectTimeoutSecs"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="120"
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Known hosts path</span>
|
||||||
|
<input
|
||||||
|
v-model="config.ssh.knownHostsPath"
|
||||||
|
placeholder="~/.ssh/known_hosts"
|
||||||
|
:disabled="!config.ssh.strictHostKey"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field checkbox">
|
||||||
|
<input type="checkbox" v-model="config.ssh.strictHostKey" />
|
||||||
|
<span>
|
||||||
|
Strict host key checking (recommended for production hosts)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="!config.ssh.password" class="hint warn">
|
||||||
|
Password is empty — please make sure password-less SSH is configured on
|
||||||
|
this machine (e.g. via <code>ssh-copy-id</code> or your <code>ssh-agent</code>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" :disabled="testingSSH" @click="testSSH">
|
||||||
|
{{ testingSSH ? 'Testing…' : 'Test connection' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn primary" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="message"
|
v-if="message"
|
||||||
@@ -204,7 +362,7 @@ onMounted(load)
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
@@ -224,6 +382,9 @@ kbd {
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab strip removed — sidebar in App.vue now drives section selection. */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
@@ -258,6 +419,7 @@ kbd {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.field input[type='text'],
|
.field input[type='text'],
|
||||||
|
.field input[type='number'],
|
||||||
.field input:not([type='checkbox']) {
|
.field input:not([type='checkbox']) {
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -267,6 +429,8 @@ kbd {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.field input:focus {
|
.field input:focus {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
@@ -278,11 +442,48 @@ kbd {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
.field input:disabled {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.prefixed-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.prefixed-input:focus-within {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
|
||||||
|
}
|
||||||
|
.input-prefix {
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
border-right: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
.prefixed-input input {
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
flex: 1;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
.prefixed-input input:focus {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
.hint.warn {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
.hint code {
|
.hint code {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
@@ -359,6 +560,19 @@ kbd {
|
|||||||
border-color: #374151;
|
border-color: #374151;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
.field input:disabled {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.prefixed-input {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
.input-prefix {
|
||||||
|
background: #1f2937;
|
||||||
|
border-right-color: #374151;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
.btn {
|
.btn {
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
border-color: #374151;
|
border-color: #374151;
|
||||||
|
|||||||
Vendored
+6
@@ -29,8 +29,14 @@ export function SaveNativeRegionImage(arg1:main.CaptureResult):Promise<main.Capt
|
|||||||
|
|
||||||
export function SaveNativeRegionImageToDir(arg1:main.CaptureResult,arg2:string):Promise<main.CaptureActionResult>;
|
export function SaveNativeRegionImageToDir(arg1:main.CaptureResult,arg2:string):Promise<main.CaptureActionResult>;
|
||||||
|
|
||||||
|
export function SaveNativeRegionToRemote(arg1:main.CaptureResult):Promise<void>;
|
||||||
|
|
||||||
export function SaveRegionImage(arg1:main.CaptureResult):Promise<main.CaptureActionResult>;
|
export function SaveRegionImage(arg1:main.CaptureResult):Promise<main.CaptureActionResult>;
|
||||||
|
|
||||||
|
export function SaveRegionToRemote(arg1:main.CaptureResult):Promise<void>;
|
||||||
|
|
||||||
export function ShowWindow():Promise<void>;
|
export function ShowWindow():Promise<void>;
|
||||||
|
|
||||||
export function TestConnection(arg1:domain.S3Config):Promise<void>;
|
export function TestConnection(arg1:domain.S3Config):Promise<void>;
|
||||||
|
|
||||||
|
export function TestSSHConnection(arg1:domain.SSHConfig):Promise<void>;
|
||||||
|
|||||||
@@ -54,10 +54,18 @@ export function SaveNativeRegionImageToDir(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SaveNativeRegionImageToDir'](arg1, arg2);
|
return window['go']['main']['App']['SaveNativeRegionImageToDir'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveNativeRegionToRemote(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveNativeRegionToRemote'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRegionImage(arg1) {
|
export function SaveRegionImage(arg1) {
|
||||||
return window['go']['main']['App']['SaveRegionImage'](arg1);
|
return window['go']['main']['App']['SaveRegionImage'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRegionToRemote(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRegionToRemote'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ShowWindow() {
|
export function ShowWindow() {
|
||||||
return window['go']['main']['App']['ShowWindow']();
|
return window['go']['main']['App']['ShowWindow']();
|
||||||
}
|
}
|
||||||
@@ -65,3 +73,7 @@ export function ShowWindow() {
|
|||||||
export function TestConnection(arg1) {
|
export function TestConnection(arg1) {
|
||||||
return window['go']['main']['App']['TestConnection'](arg1);
|
return window['go']['main']['App']['TestConnection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestSSHConnection(arg1) {
|
||||||
|
return window['go']['main']['App']['TestSSHConnection'](arg1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,32 @@ export namespace application {
|
|||||||
|
|
||||||
export namespace domain {
|
export namespace domain {
|
||||||
|
|
||||||
|
export class SSHConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
pathPrefix: string;
|
||||||
|
strictHostKey: boolean;
|
||||||
|
knownHostsPath: string;
|
||||||
|
connectTimeoutSecs: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new SSHConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.password = source["password"];
|
||||||
|
this.pathPrefix = source["pathPrefix"];
|
||||||
|
this.strictHostKey = source["strictHostKey"];
|
||||||
|
this.knownHostsPath = source["knownHostsPath"];
|
||||||
|
this.connectTimeoutSecs = source["connectTimeoutSecs"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class S3Config {
|
export class S3Config {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
region: string;
|
region: string;
|
||||||
@@ -82,6 +108,7 @@ export namespace domain {
|
|||||||
export class AppConfig {
|
export class AppConfig {
|
||||||
hotkey: string;
|
hotkey: string;
|
||||||
s3: S3Config;
|
s3: S3Config;
|
||||||
|
ssh: SSHConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AppConfig(source);
|
return new AppConfig(source);
|
||||||
@@ -91,6 +118,7 @@ export namespace domain {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.hotkey = source["hotkey"];
|
this.hotkey = source["hotkey"];
|
||||||
this.s3 = this.convertValues(source["s3"], S3Config);
|
this.s3 = this.convertValues(source["s3"], S3Config);
|
||||||
|
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -112,6 +140,7 @@ export namespace domain {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ require (
|
|||||||
github.com/wailsapp/wails/v2 v2.12.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
golang.design/x/clipboard v0.7.0
|
golang.design/x/clipboard v0.7.0
|
||||||
golang.design/x/hotkey v0.4.1
|
golang.design/x/hotkey v0.4.1
|
||||||
|
golang.org/x/crypto v0.33.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -50,7 +51,6 @@ require (
|
|||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect
|
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect
|
||||||
golang.org/x/image v0.12.0 // indirect
|
golang.org/x/image v0.12.0 // indirect
|
||||||
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect
|
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
|
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// capture_ssh.go — application-level service that uploads a screenshot to
|
||||||
|
// a remote host via SSH/SCP.
|
||||||
|
//
|
||||||
|
// Design rationale:
|
||||||
|
// - Mirrors CaptureAndUploadService so the UI layer can call a single
|
||||||
|
// well-defined entrypoint per destination kind.
|
||||||
|
// - Depends on a small Uploader interface rather than the concrete SSH
|
||||||
|
// adapter so the service is unit-testable without a real SSH server.
|
||||||
|
// - The remote object key is generated through the same date-based
|
||||||
|
// scheme used for S3 uploads (see buildObjectKey in capture_upload.go)
|
||||||
|
// so users get a consistent layout regardless of destination.
|
||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mmmy/snapgo/internal/domain"
|
||||||
|
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sshSvcLog returns an application-layer SSH logger bound to the CURRENT
|
||||||
|
// default handler.
|
||||||
|
//
|
||||||
|
// 为什么是函数而非包级 slog.With 变量: 包级变量会在 main() 调用
|
||||||
|
// logging.Init() 之前被初始化, 捕获到 bootstrap handler, 之后 SetDefault
|
||||||
|
// 切换 handler 时会产生双前缀日志. 惰性读取 slog.Default() 可避免该问题.
|
||||||
|
func sshSvcLog() *slog.Logger { return slog.Default().With("component", "application.ssh") }
|
||||||
|
|
||||||
|
// SSHUploader abstracts the SSH/SCP adapter so this layer does not depend
|
||||||
|
// on golang.org/x/crypto/ssh directly. The infrastructure package wires a
|
||||||
|
// concrete implementation through a small adapter below.
|
||||||
|
type SSHUploader interface {
|
||||||
|
// Upload writes `data` to remoteRelPath (relative to the user's $HOME)
|
||||||
|
// with the given file mode and returns once the remote has acknowledged
|
||||||
|
// the transfer.
|
||||||
|
Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureAndSSHService wires capture-bytes → SCP → clipboard → notify.
|
||||||
|
type CaptureAndSSHService struct {
|
||||||
|
Uploader SSHUploader
|
||||||
|
Clipboard clipboard.Writer
|
||||||
|
Notifier Notifier
|
||||||
|
|
||||||
|
// Cfg is the active SSH configuration; we keep a copy on the service
|
||||||
|
// so PathPrefix and PublicURLBase are explicitly part of the contract
|
||||||
|
// rather than reaching into a shared global.
|
||||||
|
Cfg domain.SSHConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteWithBytes uploads previously-captured PNG bytes via SCP and copies
|
||||||
|
// either a public URL (if configured) or the remote-relative path to the
|
||||||
|
// clipboard. Either is useful: a URL for sharing, a path so the user can
|
||||||
|
// inspect / scp it manually.
|
||||||
|
func (s *CaptureAndSSHService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
|
||||||
|
if s.Uploader == nil {
|
||||||
|
sshSvcLog().Warn("ExecuteWithBytes aborted: uploader nil")
|
||||||
|
s.notifyFailure("SSH not configured")
|
||||||
|
return fmt.Errorf("ssh uploader is nil")
|
||||||
|
}
|
||||||
|
if len(pngBytes) == 0 {
|
||||||
|
sshSvcLog().Warn("ExecuteWithBytes aborted: empty screenshot")
|
||||||
|
s.notifyFailure("empty screenshot")
|
||||||
|
return fmt.Errorf("empty screenshot")
|
||||||
|
}
|
||||||
|
relPath := buildRemoteRelPath(s.Cfg.PathPrefix)
|
||||||
|
sshSvcLog().Info("save-remote pipeline start",
|
||||||
|
"host", s.Cfg.Host, "user", s.Cfg.User, "port", s.Cfg.Port,
|
||||||
|
"size", len(pngBytes), "remote_path", relPath,
|
||||||
|
"strict_host_key", s.Cfg.StrictHostKey)
|
||||||
|
start := time.Now()
|
||||||
|
if err := s.Uploader.Upload(ctx, relPath, pngBytes, 0o644); err != nil {
|
||||||
|
sshSvcLog().Error("save-remote upload failed",
|
||||||
|
"remote_path", relPath, "elapsed", time.Since(start), "err", err)
|
||||||
|
s.notifyFailure("ssh upload failed: " + err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sshSvcLog().Info("save-remote upload ok",
|
||||||
|
"remote_path", relPath, "elapsed", time.Since(start))
|
||||||
|
|
||||||
|
clipText := remoteShareText(relPath)
|
||||||
|
if s.Clipboard != nil {
|
||||||
|
if err := s.Clipboard.WriteText(clipText); err != nil {
|
||||||
|
sshSvcLog().Error("save-remote clipboard write failed", "err", err)
|
||||||
|
s.notifyFailure("clipboard write failed: " + err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sshSvcLog().Debug("save-remote clipboard write ok", "text", clipText)
|
||||||
|
}
|
||||||
|
if s.Notifier != nil {
|
||||||
|
s.Notifier.NotifySuccess(clipText)
|
||||||
|
}
|
||||||
|
sshSvcLog().Info("save-remote pipeline done",
|
||||||
|
"remote_path", relPath, "total_elapsed", time.Since(start))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteShareText returns the clipboard string for an SSH upload.
|
||||||
|
//
|
||||||
|
// 设计理由: 用户反馈只需要远端机器上的路径 (方便登录后直接定位文件),
|
||||||
|
// 不需要 user@host 前缀, 也不再支持 Public URL Base. 这里统一返回
|
||||||
|
// "~/<relPath>", 即相对远端 $HOME 的可读路径.
|
||||||
|
func remoteShareText(relPath string) string {
|
||||||
|
return "~/" + relPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRemoteRelPath produces the remote-relative target path with the
|
||||||
|
// same date-grouped layout used by the S3 pipeline.
|
||||||
|
func buildRemoteRelPath(prefix string) string {
|
||||||
|
prefix = strings.TrimSpace(prefix)
|
||||||
|
prefix = strings.TrimPrefix(prefix, "~")
|
||||||
|
prefix = strings.TrimPrefix(prefix, "/")
|
||||||
|
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return fmt.Sprintf("%s%s/%s/%s-%s.png",
|
||||||
|
prefix,
|
||||||
|
now.Format("2006"),
|
||||||
|
now.Format("01"),
|
||||||
|
now.Format("20060102-150405"),
|
||||||
|
randomSuffix(6),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaptureAndSSHService) notifyFailure(reason string) {
|
||||||
|
if s.Notifier != nil {
|
||||||
|
s.Notifier.NotifyFailure(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Package domain — configuration types.
|
// Package domain — configuration types.
|
||||||
//
|
//
|
||||||
// S3Config is intentionally split into its own file so that future providers
|
// S3Config and SSHConfig are intentionally split into their own structs so
|
||||||
// can introduce their own configuration types side-by-side without polluting
|
// that future providers can introduce their own configuration types side-
|
||||||
// the core domain types file.
|
// by-side without polluting the core domain types file.
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
// S3Config describes the connection parameters for any S3-compatible
|
// S3Config describes the connection parameters for any S3-compatible
|
||||||
@@ -27,10 +27,41 @@ type S3Config struct {
|
|||||||
UsePathStyle bool `json:"usePathStyle"`
|
UsePathStyle bool `json:"usePathStyle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSHConfig describes the parameters required to upload a screenshot via
|
||||||
|
// SSH/SCP to a remote host.
|
||||||
|
//
|
||||||
|
// Field design notes:
|
||||||
|
// - Host / Port form the destination endpoint. Port defaults to 22 when 0.
|
||||||
|
// - User is the SSH login name.
|
||||||
|
// - Password is optional; when empty the implementation falls back to the
|
||||||
|
// local SSH agent or ~/.ssh/id_* keys (i.e. the user is responsible for
|
||||||
|
// having password-less access already configured on this machine).
|
||||||
|
// - PathPrefix is interpreted *relative to the remote user's $HOME*. The
|
||||||
|
// UI presents it as "~/<PathPrefix>" so the user cannot escape the home
|
||||||
|
// directory by writing absolute paths. Leading "/" or "~" markers are
|
||||||
|
// stripped before the path is used.
|
||||||
|
// - StrictHostKey toggles host key verification. When false the client
|
||||||
|
// uses ssh.InsecureIgnoreHostKey() to mimic `scp -o StrictHostKeyChecking=no`,
|
||||||
|
// trading some security for first-launch usability on personal LANs.
|
||||||
|
// - KnownHostsPath defaults to ~/.ssh/known_hosts when empty and is only
|
||||||
|
// used while StrictHostKey is true.
|
||||||
|
// - ConnectTimeoutSecs caps the network handshake duration so a wrong
|
||||||
|
// endpoint does not hang the capture flow indefinitely.
|
||||||
|
type SSHConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
PathPrefix string `json:"pathPrefix"`
|
||||||
|
StrictHostKey bool `json:"strictHostKey"`
|
||||||
|
KnownHostsPath string `json:"knownHostsPath"`
|
||||||
|
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
|
||||||
|
}
|
||||||
|
|
||||||
// AppConfig is the top-level on-disk configuration document.
|
// AppConfig is the top-level on-disk configuration document.
|
||||||
//
|
//
|
||||||
// We keep S3 nested so that adding more providers later (e.g. AliyunOSS, COS)
|
// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS,
|
||||||
// only requires a new sibling field rather than a schema rewrite.
|
// COS, FTP) only requires a new sibling field rather than a schema rewrite.
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
// Hotkey describes the global shortcut that triggers a capture.
|
// Hotkey describes the global shortcut that triggers a capture.
|
||||||
// Stored as a human-readable string like "cmd+shift+a"; parsing happens
|
// Stored as a human-readable string like "cmd+shift+a"; parsing happens
|
||||||
@@ -39,6 +70,10 @@ type AppConfig struct {
|
|||||||
|
|
||||||
// S3 holds the active S3-compatible storage configuration.
|
// S3 holds the active S3-compatible storage configuration.
|
||||||
S3 S3Config `json:"s3"`
|
S3 S3Config `json:"s3"`
|
||||||
|
|
||||||
|
// SSH holds the configuration for the optional "save to remote via scp"
|
||||||
|
// destination triggered by the save-remote toolbar button.
|
||||||
|
SSH SSHConfig `json:"ssh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultAppConfig returns sane zero-value defaults used on first launch.
|
// DefaultAppConfig returns sane zero-value defaults used on first launch.
|
||||||
@@ -49,6 +84,12 @@ func DefaultAppConfig() AppConfig {
|
|||||||
PathPrefix: "snapgo/",
|
PathPrefix: "snapgo/",
|
||||||
UsePathStyle: true,
|
UsePathStyle: true,
|
||||||
},
|
},
|
||||||
|
SSH: SSHConfig{
|
||||||
|
Port: 22,
|
||||||
|
PathPrefix: "snapgo/",
|
||||||
|
ConnectTimeoutSecs: 10,
|
||||||
|
StrictHostKey: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,3 +100,10 @@ func (c AppConfig) IsS3Configured() bool {
|
|||||||
c.S3.AccessKeyID != "" &&
|
c.S3.AccessKeyID != "" &&
|
||||||
c.S3.SecretAccessKey != ""
|
c.S3.SecretAccessKey != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSSHConfigured reports whether the SSH destination has the minimum
|
||||||
|
// fields required to attempt a connection. Password is intentionally NOT
|
||||||
|
// checked because empty password means "use agent / key auth".
|
||||||
|
func (c AppConfig) IsSSHConfigured() bool {
|
||||||
|
return c.SSH.Host != "" && c.SSH.User != ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Package logging centralises slog configuration.
|
||||||
|
//
|
||||||
|
// 设计动机:
|
||||||
|
// - SnapGo 是 macOS GUI 应用, 由 Finder/open 启动时 stderr 会被重定向
|
||||||
|
// 到 /dev/null, slog 默认 handler 写到 stderr 因此对用户不可见.
|
||||||
|
// - 为方便排查网络 / 权限 / SCP 协议层问题, 我们将日志同时写入磁盘文件
|
||||||
|
// 和 stderr, 让 `tail -f` 与开发期 `wails dev` 都能直接看到输出.
|
||||||
|
// - 单独抽出包可避免 main / app 直接依赖文件 IO 细节, 也方便后续替换为
|
||||||
|
// os_log 或集中式日志方案.
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxLogSize is the byte threshold that triggers a rotation. 5 MiB keeps
|
||||||
|
// a useful amount of recent history while staying small enough to open
|
||||||
|
// in any editor.
|
||||||
|
maxLogSize = 5 * 1024 * 1024
|
||||||
|
// maxBackups is how many rotated files to retain (snapgo.log.1,
|
||||||
|
// snapgo.log.2). Older ones are deleted on each rotation.
|
||||||
|
maxBackups = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// teeWriter is a thin io.Writer that fans out a single slog write to many
|
||||||
|
// underlying writers. We avoid log.MultiWriter from the std library because
|
||||||
|
// io.MultiWriter already covers the use case without an extra dependency.
|
||||||
|
type teeWriter struct {
|
||||||
|
writers []io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write copies p to every underlying writer. We deliberately keep going on
|
||||||
|
// errors so a single broken writer (e.g. closed file handle) does not lose
|
||||||
|
// output to the surviving writers; the last error wins which is sufficient
|
||||||
|
// for a logger.
|
||||||
|
func (t *teeWriter) Write(p []byte) (int, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, w := range t.writers {
|
||||||
|
if w == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := w.Write(p); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(p), lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init wires slog.Default to write to both ~/Library/Logs/SnapGo/snapgo.log
|
||||||
|
// and stderr. Returns a closer the caller (main) should defer so the file
|
||||||
|
// handle is released on shutdown.
|
||||||
|
//
|
||||||
|
// 日志级别可通过环境变量 SNAPGO_LOG_LEVEL 调整: debug|info|warn|error.
|
||||||
|
// 默认 debug 以便排查 SCP 阶段性问题, 待功能稳定后可调整为 info.
|
||||||
|
func Init() (closer func()) {
|
||||||
|
level := parseLevel(os.Getenv("SNAPGO_LOG_LEVEL"))
|
||||||
|
|
||||||
|
var fileW io.Writer
|
||||||
|
logPath, err := defaultLogPath()
|
||||||
|
if err == nil {
|
||||||
|
// Size-based rotation keeps the log directory bounded; append mode is
|
||||||
|
// preserved inside rotatingFile so prior-launch context survives.
|
||||||
|
rf, ferr := newRotatingFile(logPath, maxLogSize, maxBackups)
|
||||||
|
if ferr == nil {
|
||||||
|
fileW = rf
|
||||||
|
closer = func() { _ = rf.Close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closer == nil {
|
||||||
|
closer = func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer := &teeWriter{writers: []io.Writer{os.Stderr}}
|
||||||
|
if fileW != nil {
|
||||||
|
writer.writers = append(writer.writers, fileW)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewTextHandler(writer, &slog.HandlerOptions{Level: level})
|
||||||
|
slog.SetDefault(slog.New(handler))
|
||||||
|
|
||||||
|
slog.Info("logging initialised",
|
||||||
|
"level", level.String(),
|
||||||
|
"file", logPath)
|
||||||
|
return closer
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotatingFile is a size-based rotating log writer.
|
||||||
|
//
|
||||||
|
// 设计理由:
|
||||||
|
// - GUI 应用长期 append 写日志, 不加限制会无限增长, 占满磁盘.
|
||||||
|
// - 标准库不提供滚动能力, 引入 lumberjack 等第三方库又会增加依赖,
|
||||||
|
// 而我们的需求很简单 (单文件 / 按大小 / 固定份数), 自实现成本更低.
|
||||||
|
// - 实现策略: 每次 Write 前检查写入后是否超过 maxLogSize, 超过则先
|
||||||
|
// rotate (snapgo.log -> snapgo.log.1 -> snapgo.log.2, 丢弃最老的),
|
||||||
|
// 再写入新建的 snapgo.log. 用互斥锁保证并发安全 (slog handler 自身
|
||||||
|
// 不保证对 io.Writer 的串行化).
|
||||||
|
type rotatingFile struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
maxSize int64
|
||||||
|
backups int
|
||||||
|
file *os.File
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRotatingFile opens (or creates, append mode) the target log file and
|
||||||
|
// initialises the current size from the existing file so a restart does not
|
||||||
|
// reset the rotation threshold.
|
||||||
|
func newRotatingFile(path string, maxSize int64, backups int) (*rotatingFile, error) {
|
||||||
|
r := &rotatingFile{path: path, maxSize: maxSize, backups: backups}
|
||||||
|
if err := r.openExisting(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openExisting opens the active log file in append mode and records its
|
||||||
|
// current size.
|
||||||
|
func (r *rotatingFile) openExisting() error {
|
||||||
|
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.file = f
|
||||||
|
r.size = info.Size()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write appends p, rotating first when the write would exceed maxSize.
|
||||||
|
func (r *rotatingFile) Write(p []byte) (int, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.size+int64(len(p)) > r.maxSize {
|
||||||
|
if err := r.rotate(); err != nil {
|
||||||
|
// On rotation failure we keep writing to the current file rather
|
||||||
|
// than dropping logs; oversized-by-a-bit beats data loss.
|
||||||
|
r.size += int64(len(p))
|
||||||
|
n, werr := r.file.Write(p)
|
||||||
|
if werr != nil {
|
||||||
|
return n, werr
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := r.file.Write(p)
|
||||||
|
r.size += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotate closes the current file, shifts backups (snapgo.log.1 ->
|
||||||
|
// snapgo.log.2, dropping the oldest), renames the active file to
|
||||||
|
// snapgo.log.1, then opens a fresh active file.
|
||||||
|
func (r *rotatingFile) rotate() error {
|
||||||
|
if err := r.file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Drop the oldest backup, then shift the rest up by one index.
|
||||||
|
oldest := fmt.Sprintf("%s.%d", r.path, r.backups)
|
||||||
|
_ = os.Remove(oldest)
|
||||||
|
for i := r.backups - 1; i >= 1; i-- {
|
||||||
|
src := fmt.Sprintf("%s.%d", r.path, i)
|
||||||
|
dst := fmt.Sprintf("%s.%d", r.path, i+1)
|
||||||
|
_ = os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
if r.backups >= 1 {
|
||||||
|
_ = os.Rename(r.path, fmt.Sprintf("%s.1", r.path))
|
||||||
|
}
|
||||||
|
return r.openExisting()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying file handle.
|
||||||
|
func (r *rotatingFile) Close() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultLogPath returns ~/Library/Logs/SnapGo/snapgo.log, creating the
|
||||||
|
// parent directory if needed. macOS users expect app logs to live there
|
||||||
|
// (Console.app surfaces this directory under "Reports" and `open ~/Library/Logs`
|
||||||
|
// is muscle-memory).
|
||||||
|
func defaultLogPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(home, "Library", "Logs", "SnapGo")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "snapgo.log"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLevel translates a free-form string into an slog.Level. We default
|
||||||
|
// to debug because the app currently has plenty of diagnostic logs and the
|
||||||
|
// user-facing impact (slightly more disk IO on uploads) is negligible.
|
||||||
|
func parseLevel(s string) slog.Level {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
case "warn", "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "info":
|
||||||
|
return slog.LevelInfo
|
||||||
|
case "debug", "":
|
||||||
|
return slog.LevelDebug
|
||||||
|
default:
|
||||||
|
return slog.LevelDebug
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
// Package ssh provides an SCP-based remote upload adapter for SnapGo.
|
||||||
|
//
|
||||||
|
// Design rationale:
|
||||||
|
// - We deliberately implement SCP "sink mode" by hand on top of an SSH
|
||||||
|
// session instead of pulling in an extra dependency. The protocol is
|
||||||
|
// trivial (one control line + payload + null byte) and we only need
|
||||||
|
// write support, so a custom implementation keeps the dependency
|
||||||
|
// surface small and auditable.
|
||||||
|
// - The adapter is split into a Client (connection lifetime) and a
|
||||||
|
// CopyFile method (single transfer) so future use-cases such as listing
|
||||||
|
// or deleting remote files can extend the same SSH session.
|
||||||
|
// - All public methods accept a context so the application layer can
|
||||||
|
// enforce a timeout that matches the user-perceived capture latency.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
|
||||||
|
"github.com/mmmy/snapgo/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logger returns a component-scoped slog logger bound to the CURRENT
|
||||||
|
// default handler.
|
||||||
|
//
|
||||||
|
// 为什么是函数而非包级 slog.With 变量:
|
||||||
|
// - 包级变量在 main() 调用 logging.Init() 之前就初始化, 那一刻
|
||||||
|
// slog.Default() 还是 bootstrap handler (它转发给标准 log 包).
|
||||||
|
// - logging.Init() 里的 slog.SetDefault() 会把标准 log 包重定向回新的
|
||||||
|
// TextHandler, 于是旧 handler 先把整行格式化成 "INFO msg component=ssh..."
|
||||||
|
// 再被新 handler 当成一个 msg 二次包裹, 产生双前缀日志.
|
||||||
|
// - 每次惰性读取 slog.Default() 即可始终拿到正确的目标 handler.
|
||||||
|
func sshLog() *slog.Logger { return slog.Default().With("component", "ssh") }
|
||||||
|
|
||||||
|
// Client is a thin wrapper around *ssh.Client whose lifetime maps 1:1
|
||||||
|
// to a single upload operation. We do not pool connections because the
|
||||||
|
// expected cadence (a few uploads per minute at most) does not justify
|
||||||
|
// the additional complexity of managing keep-alives.
|
||||||
|
type Client struct {
|
||||||
|
client *ssh.Client
|
||||||
|
cfg domain.SSHConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial establishes an SSH connection using the supplied configuration.
|
||||||
|
//
|
||||||
|
// Authentication priority:
|
||||||
|
// 1. Password, if non-empty.
|
||||||
|
// 2. Local ssh-agent ($SSH_AUTH_SOCK), if present.
|
||||||
|
// 3. ~/.ssh/id_ed25519 → id_rsa fallback files.
|
||||||
|
//
|
||||||
|
// Host-key verification follows cfg.StrictHostKey. When strict, the user-
|
||||||
|
// supplied known_hosts file is honoured (defaulting to ~/.ssh/known_hosts).
|
||||||
|
// When false the call uses ssh.InsecureIgnoreHostKey, which is a deliberate
|
||||||
|
// trade-off that surfaces in the UI — the settings page warns the user.
|
||||||
|
func Dial(ctx context.Context, cfg domain.SSHConfig) (*Client, error) {
|
||||||
|
if cfg.Host == "" || cfg.User == "" {
|
||||||
|
return nil, fmt.Errorf("ssh: host and user are required")
|
||||||
|
}
|
||||||
|
port := cfg.Port
|
||||||
|
if port <= 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
timeout := time.Duration(cfg.ConnectTimeoutSecs) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
authMethods, authSummary, err := buildAuthMethods(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(authMethods) == 0 {
|
||||||
|
sshLog().Warn("dial aborted: no auth methods",
|
||||||
|
"host", cfg.Host, "user", cfg.User, "auth_summary", authSummary)
|
||||||
|
return nil, fmt.Errorf("ssh: no authentication methods available (set password or ensure ssh-agent / ~/.ssh/id_* exists)")
|
||||||
|
}
|
||||||
|
|
||||||
|
hostKeyCallback, hostKeyMode, err := buildHostKeyCallback(cfg)
|
||||||
|
if err != nil {
|
||||||
|
sshLog().Error("host key callback failed",
|
||||||
|
"host", cfg.Host, "strict", cfg.StrictHostKey,
|
||||||
|
"known_hosts", cfg.KnownHostsPath, "err", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCfg := &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Auth: authMethods,
|
||||||
|
HostKeyCallback: hostKeyCallback,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
|
||||||
|
|
||||||
|
sshLog().Info("dial start",
|
||||||
|
"addr", addr, "user", cfg.User,
|
||||||
|
"timeout", timeout, "auth_summary", authSummary, "host_key", hostKeyMode)
|
||||||
|
|
||||||
|
dialStart := time.Now()
|
||||||
|
// Honour ctx by dialing through net.Dialer so an early ctx cancel
|
||||||
|
// surfaces here instead of after the (long) ssh handshake.
|
||||||
|
dialer := net.Dialer{Timeout: timeout}
|
||||||
|
tcpConn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
sshLog().Error("tcp dial failed",
|
||||||
|
"addr", addr, "elapsed", time.Since(dialStart), "err", err)
|
||||||
|
return nil, fmt.Errorf("ssh: dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
sshLog().Debug("tcp connected", "addr", addr, "elapsed", time.Since(dialStart))
|
||||||
|
|
||||||
|
hsStart := time.Now()
|
||||||
|
sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, clientCfg)
|
||||||
|
if err != nil {
|
||||||
|
_ = tcpConn.Close()
|
||||||
|
sshLog().Error("ssh handshake failed",
|
||||||
|
"addr", addr, "user", cfg.User,
|
||||||
|
"elapsed", time.Since(hsStart), "err", err)
|
||||||
|
return nil, fmt.Errorf("ssh: handshake: %w", err)
|
||||||
|
}
|
||||||
|
sshLog().Info("ssh handshake ok",
|
||||||
|
"addr", addr, "user", cfg.User,
|
||||||
|
"server_version", string(sshConn.ServerVersion()),
|
||||||
|
"elapsed", time.Since(hsStart))
|
||||||
|
return &Client{
|
||||||
|
client: ssh.NewClient(sshConn, chans, reqs),
|
||||||
|
cfg: cfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the underlying SSH connection.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
if c == nil || c.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyFile uploads `data` to remoteRelativePath under the user's home
|
||||||
|
// directory. The caller passes a path *relative to $HOME*; the adapter
|
||||||
|
// strips any leading "/" or "~" so users cannot escape their home
|
||||||
|
// directory through misconfigured PathPrefix values.
|
||||||
|
//
|
||||||
|
// The upload uses the SCP "sink mode" protocol:
|
||||||
|
//
|
||||||
|
// $ scp -t <remote-dir>
|
||||||
|
// ← we send: D0755 0 <subdir>\n (mkdir -p analogue, repeated)
|
||||||
|
// ← we send: C0644 <size> <basename>\n
|
||||||
|
// ← we send: <bytes...>\0
|
||||||
|
// ← we send: E\n (close each directory)
|
||||||
|
//
|
||||||
|
// Each line we write is acknowledged by a single 0x00 byte from the remote
|
||||||
|
// `scp -t` process; a non-zero ack indicates an error.
|
||||||
|
func (c *Client) CopyFile(ctx context.Context, remoteRelativePath string, data []byte, mode os.FileMode) error {
|
||||||
|
cleaned := normaliseRemotePath(remoteRelativePath)
|
||||||
|
if cleaned == "" {
|
||||||
|
return fmt.Errorf("ssh: remote path is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, base := path.Split(cleaned)
|
||||||
|
dir = strings.Trim(dir, "/")
|
||||||
|
|
||||||
|
sshLog().Info("scp copy start",
|
||||||
|
"remote_path", cleaned, "dir", dir, "file", base,
|
||||||
|
"size", len(data), "mode", fmt.Sprintf("%#o", mode.Perm()))
|
||||||
|
|
||||||
|
session, err := c.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
sshLog().Error("scp new session failed", "err", err)
|
||||||
|
return fmt.Errorf("ssh: new session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ssh: stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ssh: stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `-t` puts the remote scp into "sink mode" rooted at $HOME. `-r`
|
||||||
|
// allows us to feed `D` directives so we can create directory trees
|
||||||
|
// in one round-trip rather than running a separate `mkdir -p`.
|
||||||
|
cmd := fmt.Sprintf("scp -tr %s", shellQuote("./"))
|
||||||
|
sshLog().Debug("scp remote command", "cmd", cmd)
|
||||||
|
if err := session.Start(cmd); err != nil {
|
||||||
|
sshLog().Error("scp session start failed", "cmd", cmd, "err", err)
|
||||||
|
return fmt.Errorf("ssh: start scp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transferStart := time.Now()
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- writeSCPStream(stdin, stdout, dir, base, data, mode)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for either ctx, the writer goroutine, or the remote command.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
sshLog().Warn("scp cancelled by context",
|
||||||
|
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", ctx.Err())
|
||||||
|
_ = session.Signal(ssh.SIGTERM)
|
||||||
|
_ = session.Close()
|
||||||
|
return ctx.Err()
|
||||||
|
case writeErr := <-errCh:
|
||||||
|
if writeErr != nil {
|
||||||
|
sshLog().Error("scp protocol failed",
|
||||||
|
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", writeErr)
|
||||||
|
_ = session.Close()
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Wait(); err != nil {
|
||||||
|
sshLog().Error("scp session wait failed",
|
||||||
|
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", err)
|
||||||
|
return fmt.Errorf("ssh: scp wait: %w", err)
|
||||||
|
}
|
||||||
|
sshLog().Info("scp copy ok",
|
||||||
|
"remote_path", cleaned, "size", len(data), "elapsed", time.Since(transferStart))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSCPStream drives the SCP sink-mode dialogue described above. It is
|
||||||
|
// extracted from CopyFile for two reasons:
|
||||||
|
// 1. It contains the only blocking IO so we can run it in a goroutine
|
||||||
|
// and select on ctx.Done().
|
||||||
|
// 2. It makes the protocol-level steps testable in isolation (the unit
|
||||||
|
// tests pipe an in-memory bytes.Buffer into expectAck).
|
||||||
|
func writeSCPStream(stdin io.WriteCloser, stdout io.Reader, dir, base string, data []byte, mode os.FileMode) error {
|
||||||
|
defer stdin.Close()
|
||||||
|
|
||||||
|
// First ack: the remote scp -t emits an initial 0x00 once it is ready.
|
||||||
|
if err := expectAck(stdout); err != nil {
|
||||||
|
return fmt.Errorf("scp: initial ack: %w", err)
|
||||||
|
}
|
||||||
|
sshLog().Debug("scp initial ack received")
|
||||||
|
|
||||||
|
// Walk `dir` segment by segment, opening each level with `D0755 0 <name>`.
|
||||||
|
dirs := splitNonEmpty(dir, "/")
|
||||||
|
for _, segment := range dirs {
|
||||||
|
line := fmt.Sprintf("D0755 0 %s\n", segment)
|
||||||
|
if _, err := io.WriteString(stdin, line); err != nil {
|
||||||
|
return fmt.Errorf("scp: write D-line: %w", err)
|
||||||
|
}
|
||||||
|
if err := expectAck(stdout); err != nil {
|
||||||
|
return fmt.Errorf("scp: ack D-line %q: %w", segment, err)
|
||||||
|
}
|
||||||
|
sshLog().Debug("scp dir opened", "segment", segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File header: `C<perm> <size> <name>\n`
|
||||||
|
header := fmt.Sprintf("C%04o %d %s\n", mode.Perm(), len(data), base)
|
||||||
|
if _, err := io.WriteString(stdin, header); err != nil {
|
||||||
|
return fmt.Errorf("scp: write C-line: %w", err)
|
||||||
|
}
|
||||||
|
if err := expectAck(stdout); err != nil {
|
||||||
|
return fmt.Errorf("scp: ack C-line: %w", err)
|
||||||
|
}
|
||||||
|
sshLog().Debug("scp header acked", "header", strings.TrimSpace(header))
|
||||||
|
|
||||||
|
if _, err := stdin.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("scp: write payload: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := stdin.Write([]byte{0}); err != nil {
|
||||||
|
return fmt.Errorf("scp: write trailing null: %w", err)
|
||||||
|
}
|
||||||
|
if err := expectAck(stdout); err != nil {
|
||||||
|
return fmt.Errorf("scp: ack payload: %w", err)
|
||||||
|
}
|
||||||
|
sshLog().Debug("scp payload acked", "bytes", len(data))
|
||||||
|
|
||||||
|
// Pop each directory we opened earlier with an `E` line so the remote
|
||||||
|
// scp finishes cleanly.
|
||||||
|
for range dirs {
|
||||||
|
if _, err := io.WriteString(stdin, "E\n"); err != nil {
|
||||||
|
return fmt.Errorf("scp: write E-line: %w", err)
|
||||||
|
}
|
||||||
|
if err := expectAck(stdout); err != nil {
|
||||||
|
return fmt.Errorf("scp: ack E-line: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectAck reads exactly one byte from the remote scp and treats anything
|
||||||
|
// other than 0x00 as a protocol-level error. When the remote signals an
|
||||||
|
// error (0x01 = warning, 0x02 = fatal) it is followed by a textual reason
|
||||||
|
// terminated with '\n', which we surface to the caller.
|
||||||
|
func expectAck(r io.Reader) error {
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if buf[0] == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Read the rest of the message until newline so the user sees a
|
||||||
|
// meaningful "permission denied"-style reason.
|
||||||
|
msg := readUntilNewline(r)
|
||||||
|
return fmt.Errorf("scp remote error (%d): %s", buf[0], strings.TrimSpace(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUntilNewline(r io.Reader) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for i := 0; i < 1024; i++ {
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if buf[0] == '\n' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteByte(buf[0])
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection performs a minimal handshake + `pwd` round trip so the
|
||||||
|
// settings UI can confirm the credentials work without writing a file.
|
||||||
|
func TestConnection(ctx context.Context, cfg domain.SSHConfig) error {
|
||||||
|
sshLog().Info("test connection start", "host", cfg.Host, "user", cfg.User, "port", cfg.Port)
|
||||||
|
start := time.Now()
|
||||||
|
client, err := Dial(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
sshLog().Error("test connection: dial failed", "err", err, "elapsed", time.Since(start))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
session, err := client.client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
sshLog().Error("test connection: new session failed", "err", err)
|
||||||
|
return fmt.Errorf("ssh: new session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
if err := session.Run("true"); err != nil {
|
||||||
|
sshLog().Error("test connection: probe failed", "err", err)
|
||||||
|
return fmt.Errorf("ssh: probe command failed: %w", err)
|
||||||
|
}
|
||||||
|
sshLog().Info("test connection ok", "elapsed", time.Since(start))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseRemotePath strips any leading "/" or "~" so the path is always
|
||||||
|
// interpreted relative to the remote $HOME, matching the UI promise.
|
||||||
|
func normaliseRemotePath(p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
p = strings.TrimPrefix(p, "~")
|
||||||
|
p = strings.TrimPrefix(p, "/")
|
||||||
|
p = strings.TrimPrefix(p, "./")
|
||||||
|
return path.Clean(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitNonEmpty splits `s` on `sep` and discards empty segments so the
|
||||||
|
// caller does not have to defend against double slashes etc.
|
||||||
|
func splitNonEmpty(s, sep string) []string {
|
||||||
|
parts := strings.Split(s, sep)
|
||||||
|
out := parts[:0]
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" || part == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellQuote is a defensive single-quote escaper used when interpolating
|
||||||
|
// user-controlled strings (currently only the relative root ".") into the
|
||||||
|
// remote `scp` command line. We do NOT quote arbitrary user paths because
|
||||||
|
// SCP itself receives them through the protocol channel above.
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAuthMethods chooses authentication methods given the supplied
|
||||||
|
// configuration. See Dial's docstring for the priority order.
|
||||||
|
//
|
||||||
|
// Returns a human-readable summary alongside the method slice so the
|
||||||
|
// caller can include "password+agent+ed25519" (and similar) in its dial
|
||||||
|
// log without leaking secret material.
|
||||||
|
func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) {
|
||||||
|
var methods []ssh.AuthMethod
|
||||||
|
var sources []string
|
||||||
|
if cfg.Password != "" {
|
||||||
|
methods = append(methods, ssh.Password(cfg.Password))
|
||||||
|
sources = append(sources, "password")
|
||||||
|
}
|
||||||
|
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
|
||||||
|
if conn, err := net.Dial("unix", sock); err == nil {
|
||||||
|
// 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods.
|
||||||
|
//
|
||||||
|
// macOS 的 launchd ssh-agent 即便没有任何 identity 也会响应,
|
||||||
|
// 此时若仍注册 PublicKeysCallback, x/crypto 会把它当作一次空的
|
||||||
|
// publickey 尝试. 配合服务器的 MaxAuthTries 计数, 这次"空尝试"
|
||||||
|
// 会挤占后续基于磁盘私钥的认证机会, 最终导致
|
||||||
|
// "[none publickey] no supported methods remain" —— 即便磁盘上
|
||||||
|
// 的 key 本身完全可用. 因此空 agent 必须跳过.
|
||||||
|
ag := agent.NewClient(conn)
|
||||||
|
if keys, lerr := ag.List(); lerr == nil && len(keys) > 0 {
|
||||||
|
methods = append(methods, ssh.PublicKeysCallback(ag.Signers))
|
||||||
|
sources = append(sources, fmt.Sprintf("agent(%d)", len(keys)))
|
||||||
|
} else {
|
||||||
|
sshLog().Debug("ssh-agent has no identities; skipping",
|
||||||
|
"sock", sock, "list_err", lerr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sshLog().Debug("ssh-agent dial failed", "sock", sock, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
// Probe the common default keys; any unreadable / missing file is
|
||||||
|
// silently skipped so the user never sees noise about keys they did
|
||||||
|
// not set up.
|
||||||
|
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
|
||||||
|
signer, err := loadPrivateKey(path.Join(home, ".ssh", name))
|
||||||
|
if err == nil && signer != nil {
|
||||||
|
methods = append(methods, ssh.PublicKeys(signer))
|
||||||
|
sources = append(sources, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sshLog().Debug("home dir unavailable for ssh keys", "err", err)
|
||||||
|
}
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return methods, "none", nil
|
||||||
|
}
|
||||||
|
return methods, strings.Join(sources, "+"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPrivateKey reads and parses a single OpenSSH private key file.
|
||||||
|
// Returns (nil, nil) when the file does not exist so the caller can keep
|
||||||
|
// scanning the standard key list.
|
||||||
|
func loadPrivateKey(p string) (ssh.Signer, error) {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(data)
|
||||||
|
if err != nil {
|
||||||
|
// Encrypted keys without a passphrase are skipped silently for now;
|
||||||
|
// passphrase support is left to a follow-up spec.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHostKeyCallback returns either a strict known_hosts-backed callback
|
||||||
|
// or an InsecureIgnoreHostKey fallback, depending on cfg.StrictHostKey.
|
||||||
|
//
|
||||||
|
// The returned mode string ("insecure" or "known_hosts:<path>") is logged
|
||||||
|
// at dial time so a "rejected by host key" failure is easy to diagnose.
|
||||||
|
func buildHostKeyCallback(cfg domain.SSHConfig) (ssh.HostKeyCallback, string, error) {
|
||||||
|
if !cfg.StrictHostKey {
|
||||||
|
// Deliberate trade-off: matches `scp -o StrictHostKeyChecking=no` and
|
||||||
|
// keeps the first-launch experience friction-free for personal LANs.
|
||||||
|
return ssh.InsecureIgnoreHostKey(), "insecure", nil
|
||||||
|
}
|
||||||
|
knownHosts := cfg.KnownHostsPath
|
||||||
|
if knownHosts == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("ssh: resolve home for known_hosts: %w", err)
|
||||||
|
}
|
||||||
|
knownHosts = path.Join(home, ".ssh", "known_hosts")
|
||||||
|
}
|
||||||
|
cb, err := knownhosts.New(knownHosts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("ssh: load known_hosts %q: %w", knownHosts, err)
|
||||||
|
}
|
||||||
|
return cb, "known_hosts:" + knownHosts, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// uploader.go — small adapter that satisfies application.SSHUploader by
|
||||||
|
// dialing a one-shot connection per upload. This keeps the application
|
||||||
|
// layer free of any direct dependency on golang.org/x/crypto/ssh while
|
||||||
|
// still exposing a clean, unit-testable contract.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mmmy/snapgo/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Uploader is the production implementation of application.SSHUploader.
|
||||||
|
//
|
||||||
|
// We dial fresh per-upload because:
|
||||||
|
// - Captures happen sparsely (a few per minute at peak), so the cost of
|
||||||
|
// a TCP+SSH handshake is dwarfed by user think-time.
|
||||||
|
// - Holding a long-lived connection would force us to add keepalives,
|
||||||
|
// reconnection logic, and config-change invalidation — not worth it
|
||||||
|
// for the current usage profile.
|
||||||
|
type Uploader struct {
|
||||||
|
cfg domain.SSHConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUploader returns a ready-to-use uploader. The configuration is
|
||||||
|
// captured by value so a simultaneous in-flight upload cannot be re-
|
||||||
|
// targeted by a settings save.
|
||||||
|
func NewUploader(cfg domain.SSHConfig) *Uploader {
|
||||||
|
return &Uploader{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload satisfies application.SSHUploader. We log entry/exit at INFO so
|
||||||
|
// the operator sees a single line per upload in the normal happy path,
|
||||||
|
// and ERROR with elapsed time when something fails.
|
||||||
|
func (u *Uploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
|
||||||
|
start := time.Now()
|
||||||
|
sshLog().Info("uploader.Upload start",
|
||||||
|
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
|
||||||
|
"remote_path", remoteRelPath, "size", len(data))
|
||||||
|
|
||||||
|
client, err := Dial(ctx, u.cfg)
|
||||||
|
if err != nil {
|
||||||
|
sshLog().Error("uploader.Upload dial failed",
|
||||||
|
"host", u.cfg.Host, "elapsed", time.Since(start), "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
if err := client.CopyFile(ctx, remoteRelPath, data, mode); err != nil {
|
||||||
|
sshLog().Error("uploader.Upload copy failed",
|
||||||
|
"remote_path", remoteRelPath, "elapsed", time.Since(start), "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sshLog().Info("uploader.Upload done",
|
||||||
|
"remote_path", remoteRelPath, "size", len(data),
|
||||||
|
"total_elapsed", time.Since(start))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||||
|
|
||||||
|
"github.com/mmmy/snapgo/internal/infrastructure/logging"
|
||||||
"github.com/mmmy/snapgo/internal/infrastructure/tray"
|
"github.com/mmmy/snapgo/internal/infrastructure/tray"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +17,12 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Init logging FIRST so the rest of startup (config load, hotkey
|
||||||
|
// registration, tray bootstrap) is captured to ~/Library/Logs/SnapGo
|
||||||
|
// even when the binary was launched via `open` and stderr is /dev/null.
|
||||||
|
closeLog := logging.Init()
|
||||||
|
defer closeLog()
|
||||||
|
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|
||||||
// Wire up the menu-bar agent. Start() returns a `start` thunk that we
|
// Wire up the menu-bar agent. Start() returns a `start` thunk that we
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ func nativeOverlaySave(x, y, w, h C.int, annotationsJSON *C.char, dir *C.char) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//export nativeOverlaySaveRemote
|
||||||
|
func nativeOverlaySaveRemote(x, y, w, h C.int, annotationsJSON *C.char) {
|
||||||
|
app := consumeNativeOverlayApp()
|
||||||
|
if app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := nativeCaptureResult(x, y, w, h, annotationsJSON)
|
||||||
|
go func() {
|
||||||
|
_ = app.SaveNativeRegionToRemote(result)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
//export nativeOverlayCancel
|
//export nativeOverlayCancel
|
||||||
func nativeOverlayCancel() {
|
func nativeOverlayCancel() {
|
||||||
app := consumeNativeOverlayApp()
|
app := consumeNativeOverlayApp()
|
||||||
|
|||||||
+11
-11
@@ -14,6 +14,7 @@ package main
|
|||||||
extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotationsJSON);
|
extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotationsJSON);
|
||||||
extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON);
|
extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON);
|
||||||
extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir);
|
extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir);
|
||||||
|
extern void nativeOverlaySaveRemote(int x, int y, int w, int h, const char *annotationsJSON);
|
||||||
extern void nativeOverlayCancel(void);
|
extern void nativeOverlayCancel(void);
|
||||||
|
|
||||||
static NSWindow *nativeOverlayWindow = nil;
|
static NSWindow *nativeOverlayWindow = nil;
|
||||||
@@ -783,18 +784,17 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
nativeOverlayCancel();
|
nativeOverlayCancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// `saveRemoteSelection` is a placeholder action wired up to the new
|
// `saveRemoteSelection` triggers the SCP upload flow. The overlay closes
|
||||||
// "save to remote" toolbar button. The remote save flow is not yet
|
// immediately so the user can keep working while the transfer runs in the
|
||||||
// implemented, so we surface a transient NSAlert with a friendly
|
// background; success / failure surfaces through the regular Toast events.
|
||||||
// "coming soon" message and keep the overlay open so the user can
|
|
||||||
// pick a different action without re-selecting the region.
|
|
||||||
- (void)saveRemoteSelection {
|
- (void)saveRemoteSelection {
|
||||||
NSAlert *alert = [[NSAlert alloc] init];
|
if (!_hasSelection) {
|
||||||
[alert setAlertStyle:NSAlertStyleInformational];
|
return;
|
||||||
[alert setMessageText:@"暂未支持"];
|
}
|
||||||
[alert setInformativeText:@"敬请期待"];
|
NSRect r = [self globalRectForSelection:_selection];
|
||||||
[alert addButtonWithTitle:@"OK"];
|
[self closeOverlayWindow];
|
||||||
[alert runModal];
|
NSString *json = [self annotationsJSON];
|
||||||
|
nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
|
||||||
}
|
}
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user