Compare commits
2 Commits
50551de105
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c9b96fbf | |||
| b69ef00013 |
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"test": "node --test tests/*.test.ts",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
bb7ffb87329c9ad4990374471d4ce9a4
|
e2d56b98c3c8ae5968db0bbb461b5615
|
||||||
@@ -68,7 +68,7 @@ const activeTab = ref<SettingsTab>('general')
|
|||||||
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
|
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
|
||||||
{ id: 'general', label: '通用设置' },
|
{ id: 'general', label: '通用设置' },
|
||||||
{ id: 's3', label: '对象存储' },
|
{ id: 's3', label: '对象存储' },
|
||||||
{ id: 'ftp', label: 'FTP / SFTP' },
|
{ id: 'ftp', label: '文件服务' },
|
||||||
{ id: 'ssh', label: '远程主机' },
|
{ id: 'ssh', label: '远程主机' },
|
||||||
{ id: 'llm', label: '智能识图' },
|
{ id: 'llm', label: '智能识图' },
|
||||||
{ id: 'ocr', label: '文字提取' },
|
{ id: 'ocr', label: '文字提取' },
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface AnnotationPoint {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PointCollection {
|
||||||
|
points: AnnotationPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annotations are stored relative to the selection origin. Resizing from the
|
||||||
|
// top or left moves that origin, so offset the local points in the opposite
|
||||||
|
// direction to keep them attached to the same screen pixels.
|
||||||
|
export function preserveAnnotationScreenPositions(
|
||||||
|
annotations: PointCollection[],
|
||||||
|
previousOrigin: AnnotationPoint,
|
||||||
|
nextOrigin: AnnotationPoint
|
||||||
|
) {
|
||||||
|
const dx = previousOrigin.x - nextOrigin.x
|
||||||
|
const dy = previousOrigin.y - nextOrigin.y
|
||||||
|
if (dx === 0 && dy === 0) return
|
||||||
|
|
||||||
|
for (const annotation of annotations) {
|
||||||
|
for (const point of annotation.points) {
|
||||||
|
point.x += dx
|
||||||
|
point.y += dy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import saveIcon from '../assets/icons/save-local.svg?raw'
|
|||||||
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
|
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
|
||||||
import ftpIcon from '../assets/icons/ftp.svg?raw'
|
import ftpIcon from '../assets/icons/ftp.svg?raw'
|
||||||
import uploadIcon from '../assets/icons/upload.svg?raw'
|
import uploadIcon from '../assets/icons/upload.svg?raw'
|
||||||
|
import { preserveAnnotationScreenPositions } from '../utils/annotationGeometry'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
width: number
|
width: number
|
||||||
@@ -539,12 +540,16 @@ function resizeSelection(p: Point) {
|
|||||||
right = clamp(right, 0, props.width)
|
right = clamp(right, 0, props.width)
|
||||||
top = clamp(top, 0, props.height)
|
top = clamp(top, 0, props.height)
|
||||||
bottom = clamp(bottom, 0, props.height)
|
bottom = clamp(bottom, 0, props.height)
|
||||||
rect.value = {
|
const nextRect = {
|
||||||
x: Math.min(left, right),
|
x: Math.min(left, right),
|
||||||
y: Math.min(top, bottom),
|
y: Math.min(top, bottom),
|
||||||
w: Math.abs(right - left),
|
w: Math.abs(right - left),
|
||||||
h: Math.abs(bottom - top),
|
h: Math.abs(bottom - top),
|
||||||
}
|
}
|
||||||
|
if (rect.value) {
|
||||||
|
preserveAnnotationScreenPositions(annotations.value, rect.value, nextRect)
|
||||||
|
}
|
||||||
|
rect.value = nextRect
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectTool(tool: Tool) {
|
function selectTool(tool: Tool) {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface FTPConfig {
|
|||||||
user: string
|
user: string
|
||||||
authMethod: 'password' | 'key'
|
authMethod: 'password' | 'key'
|
||||||
password: string
|
password: string
|
||||||
|
rootDirectory: string
|
||||||
pathPrefix: string
|
pathPrefix: string
|
||||||
strictHostKey: boolean
|
strictHostKey: boolean
|
||||||
knownHostsPath: string
|
knownHostsPath: string
|
||||||
@@ -169,6 +170,7 @@ function defaultConfig(): AppConfig {
|
|||||||
user: '',
|
user: '',
|
||||||
authMethod: 'password',
|
authMethod: 'password',
|
||||||
password: '',
|
password: '',
|
||||||
|
rootDirectory: '/',
|
||||||
pathPrefix: 'snapgo/',
|
pathPrefix: 'snapgo/',
|
||||||
strictHostKey: false,
|
strictHostKey: false,
|
||||||
knownHostsPath: '',
|
knownHostsPath: '',
|
||||||
@@ -271,8 +273,8 @@ const sshPathDisplay = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// FTP and SFTP both upload relative to the authenticated login directory.
|
// FTP and SFTP both upload relative to the authenticated login directory.
|
||||||
// The visible prefix communicates how that location will be copied: FTP uses
|
// RootDirectory is display-only: it is prepended to the uploaded relative path
|
||||||
// its virtual root, while SFTP uses the SSH user's home directory.
|
// when that path is copied, without changing where the file is uploaded.
|
||||||
const ftpPathDisplay = computed({
|
const ftpPathDisplay = computed({
|
||||||
get: () => config.value.ftp.pathPrefix,
|
get: () => config.value.ftp.pathPrefix,
|
||||||
set: (raw: string) => {
|
set: (raw: string) => {
|
||||||
@@ -283,17 +285,26 @@ const ftpPathDisplay = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const ftpPathRootLabel = computed(() =>
|
const ftpCopiedPathPreview = computed(() => {
|
||||||
config.value.ftp.protocol === 'sftp' ? '~/' : '/'
|
const root = config.value.ftp.rootDirectory.trim().replace(/\/+$/, '')
|
||||||
)
|
const remote = config.value.ftp.pathPrefix
|
||||||
|
.trim()
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
const remotePrefix = remote ? `${remote}/` : ''
|
||||||
|
return `${root || (config.value.ftp.protocol === 'sftp' ? '~' : '')}/${remotePrefix}2026/07/20260713-110413-18lnbx.png`
|
||||||
|
})
|
||||||
|
|
||||||
function changeFTPProtocol(event: Event) {
|
function changeFTPProtocol(event: Event) {
|
||||||
const next = (event.target as HTMLSelectElement).value as FTPProtocol
|
const next = (event.target as HTMLSelectElement).value as FTPProtocol
|
||||||
const previous = config.value.ftp.protocol
|
const previous = config.value.ftp.protocol
|
||||||
const previousDefault = previous === 'sftp' ? 22 : 21
|
const previousDefault = previous === 'sftp' ? 22 : 21
|
||||||
|
const previousRootDefault = previous === 'sftp' ? '~/' : '/'
|
||||||
if (config.value.ftp.port === previousDefault) {
|
if (config.value.ftp.port === previousDefault) {
|
||||||
config.value.ftp.port = next === 'sftp' ? 22 : 21
|
config.value.ftp.port = next === 'sftp' ? 22 : 21
|
||||||
}
|
}
|
||||||
|
if (config.value.ftp.rootDirectory === previousRootDefault) {
|
||||||
|
config.value.ftp.rootDirectory = next === 'sftp' ? '~/' : '/'
|
||||||
|
}
|
||||||
config.value.ftp.protocol = next
|
config.value.ftp.protocol = next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +542,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<!-- FTP/SFTP tab — file-transfer destination for its toolbar action. -->
|
<!-- FTP/SFTP tab — file-transfer destination for its toolbar action. -->
|
||||||
<section v-if="props.tab === 'ftp'" class="card">
|
<section v-if="props.tab === 'ftp'" class="card">
|
||||||
<h2>FTP / SFTP destination</h2>
|
<h2>文件服务</h2>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Protocol</span>
|
<span>Protocol</span>
|
||||||
@@ -581,15 +592,20 @@ onMounted(load)
|
|||||||
<input v-model="config.ftp.password" type="password" />
|
<input v-model="config.ftp.password" type="password" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field full">
|
<label class="field full">
|
||||||
<span>Remote directory (under login root)</span>
|
<span>根目录(仅用于拼接复制路径)</span>
|
||||||
<div class="prefixed-input">
|
<input
|
||||||
<span class="input-prefix">{{ ftpPathRootLabel }}</span>
|
v-model.trim="config.ftp.rootDirectory"
|
||||||
|
:placeholder="config.ftp.protocol === 'sftp' ? '~/' : '/'"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field full">
|
||||||
|
<span>Remote directory(登录目录下的上传目录)</span>
|
||||||
<input
|
<input
|
||||||
v-model="ftpPathDisplay"
|
v-model="ftpPathDisplay"
|
||||||
placeholder="snapgo/"
|
placeholder="snapgo/"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Connect timeout (sec)</span>
|
<span>Connect timeout (sec)</span>
|
||||||
@@ -615,6 +631,10 @@ onMounted(load)
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
复制路径示例:<code>{{ ftpCopiedPathPreview }}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p v-if="config.ftp.protocol === 'ftp'" class="hint warn">
|
<p v-if="config.ftp.protocol === 'ftp'" class="hint warn">
|
||||||
FTP sends the username, password, and screenshot without encryption.
|
FTP sends the username, password, and screenshot without encryption.
|
||||||
Prefer SFTP on networks you do not fully trust. SFTP is not FTPS.
|
Prefer SFTP on networks you do not fully trust. SFTP is not FTPS.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { preserveAnnotationScreenPositions } from '../src/utils/annotationGeometry.ts'
|
||||||
|
|
||||||
|
test('keeps annotation points at the same screen position when the origin moves', () => {
|
||||||
|
const annotations = [
|
||||||
|
{ points: [{ x: 30, y: 40 }, { x: 80, y: 90 }] },
|
||||||
|
{ points: [{ x: 12, y: 18 }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
preserveAnnotationScreenPositions(
|
||||||
|
annotations,
|
||||||
|
{ x: 100, y: 200 },
|
||||||
|
{ x: 125, y: 230 }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(annotations, [
|
||||||
|
{ points: [{ x: 5, y: 10 }, { x: 55, y: 60 }] },
|
||||||
|
{ points: [{ x: -13, y: -12 }] },
|
||||||
|
])
|
||||||
|
assert.deepEqual(
|
||||||
|
annotations[0].points.map((point) => ({
|
||||||
|
x: 125 + point.x,
|
||||||
|
y: 230 + point.y,
|
||||||
|
})),
|
||||||
|
[{ x: 130, y: 240 }, { x: 180, y: 290 }]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not move annotations when only the right or bottom edge changes', () => {
|
||||||
|
const annotations = [{ points: [{ x: 30, y: 40 }] }]
|
||||||
|
|
||||||
|
preserveAnnotationScreenPositions(
|
||||||
|
annotations,
|
||||||
|
{ x: 100, y: 200 },
|
||||||
|
{ x: 100, y: 200 }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(annotations, [{ points: [{ x: 30, y: 40 }] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves screen positions across repeated drags and an edge crossover', () => {
|
||||||
|
const annotations = [{ points: [{ x: 50, y: 60 }] }]
|
||||||
|
|
||||||
|
preserveAnnotationScreenPositions(
|
||||||
|
annotations,
|
||||||
|
{ x: 100, y: 100 },
|
||||||
|
{ x: 140, y: 130 }
|
||||||
|
)
|
||||||
|
preserveAnnotationScreenPositions(
|
||||||
|
annotations,
|
||||||
|
{ x: 140, y: 130 },
|
||||||
|
{ x: 300, y: 150 }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(annotations, [{ points: [{ x: -150, y: 10 }] }])
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
x: 300 + annotations[0].points[0].x,
|
||||||
|
y: 150 + annotations[0].points[0].y,
|
||||||
|
},
|
||||||
|
{ x: 150, y: 160 }
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -186,6 +186,7 @@ export namespace domain {
|
|||||||
user: string;
|
user: string;
|
||||||
authMethod: string;
|
authMethod: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
rootDirectory: string;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
strictHostKey: boolean;
|
strictHostKey: boolean;
|
||||||
knownHostsPath: string;
|
knownHostsPath: string;
|
||||||
@@ -203,6 +204,7 @@ export namespace domain {
|
|||||||
this.user = source["user"];
|
this.user = source["user"];
|
||||||
this.authMethod = source["authMethod"];
|
this.authMethod = source["authMethod"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
|
this.rootDirectory = source["rootDirectory"];
|
||||||
this.pathPrefix = source["pathPrefix"];
|
this.pathPrefix = source["pathPrefix"];
|
||||||
this.strictHostKey = source["strictHostKey"];
|
this.strictHostKey = source["strictHostKey"];
|
||||||
this.knownHostsPath = source["knownHostsPath"];
|
this.knownHostsPath = source["knownHostsPath"];
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ type CaptureAndFTPService struct {
|
|||||||
Cfg domain.FTPConfig
|
Cfg domain.FTPConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteWithBytes uploads previously captured PNG bytes and copies the exact
|
// ExecuteWithBytes uploads previously captured PNG bytes and copies a display
|
||||||
// remote path semantics shown in Settings: FTP paths are rooted at "/", while
|
// path built from the configured root directory plus the uploaded relative
|
||||||
// SFTP paths are relative to the authenticated user's home directory.
|
// path. RootDirectory deliberately does not affect the upload destination.
|
||||||
func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
|
func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
|
||||||
if s.Uploader == nil {
|
if s.Uploader == nil {
|
||||||
s.notifyFailure("FTP/SFTP not configured")
|
s.notifyFailure("FTP/SFTP not configured")
|
||||||
@@ -80,7 +80,7 @@ func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
clipText := ftpShareText(protocol, relPath)
|
clipText := ftpShareText(protocol, s.Cfg.RootDirectory, relPath)
|
||||||
if s.Clipboard != nil {
|
if s.Clipboard != nil {
|
||||||
if err := s.Clipboard.WriteText(clipText); err != nil {
|
if err := s.Clipboard.WriteText(clipText); err != nil {
|
||||||
s.notifyFailure("clipboard write failed: " + err.Error())
|
s.notifyFailure("clipboard write failed: " + err.Error())
|
||||||
@@ -131,11 +131,16 @@ func normalizedFTPProtocol(protocol string) string {
|
|||||||
return domain.FTPProtocolFTP
|
return domain.FTPProtocolFTP
|
||||||
}
|
}
|
||||||
|
|
||||||
func ftpShareText(protocol, relPath string) string {
|
func ftpShareText(protocol, rootDirectory, relPath string) string {
|
||||||
|
rootDirectory = strings.TrimSpace(rootDirectory)
|
||||||
|
if rootDirectory == "" {
|
||||||
if protocol == domain.FTPProtocolSFTP {
|
if protocol == domain.FTPProtocolSFTP {
|
||||||
return "~/" + relPath
|
rootDirectory = "~/"
|
||||||
|
} else {
|
||||||
|
rootDirectory = "/"
|
||||||
}
|
}
|
||||||
return "/" + relPath
|
}
|
||||||
|
return strings.TrimRight(rootDirectory, "/") + "/" + strings.TrimLeft(relPath, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CaptureAndFTPService) notifyFailure(reason string) {
|
func (s *CaptureAndFTPService) notifyFailure(reason string) {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestCaptureAndFTPServiceUploadsAndCopiesFTPPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
|
func TestCaptureAndFTPServiceCopiesConfiguredRootPath(t *testing.T) {
|
||||||
uploader := &fakeFTPUploader{}
|
uploader := &fakeFTPUploader{}
|
||||||
clip := &fakeClipboard{}
|
clip := &fakeClipboard{}
|
||||||
svc := &CaptureAndFTPService{
|
svc := &CaptureAndFTPService{
|
||||||
@@ -60,6 +60,7 @@ func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
|
|||||||
Clipboard: clip,
|
Clipboard: clip,
|
||||||
Cfg: domain.FTPConfig{
|
Cfg: domain.FTPConfig{
|
||||||
Protocol: domain.FTPProtocolSFTP,
|
Protocol: domain.FTPProtocolSFTP,
|
||||||
|
RootDirectory: "~/ftp/",
|
||||||
PathPrefix: "images",
|
PathPrefix: "images",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -67,8 +68,36 @@ func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
|
|||||||
if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil {
|
if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil {
|
||||||
t.Fatalf("upload SFTP screenshot: %v", err)
|
t.Fatalf("upload SFTP screenshot: %v", err)
|
||||||
}
|
}
|
||||||
if clip.text != "~/"+uploader.path {
|
if clip.text != "~/ftp/"+uploader.path {
|
||||||
t.Fatalf("expected SFTP home path copied, got %q", clip.text)
|
t.Fatalf("expected configured SFTP root path copied, got %q", clip.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFTPShareTextJoinsConfiguredRootAndRemoteDirectory(t *testing.T) {
|
||||||
|
const relPath = "snapgo/2026/07/20260713-110413-18lnbx.png"
|
||||||
|
const want = "~/ftp/snapgo/2026/07/20260713-110413-18lnbx.png"
|
||||||
|
if got := ftpShareText(domain.FTPProtocolSFTP, "~/ftp/", relPath); got != want {
|
||||||
|
t.Fatalf("ftpShareText() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFTPShareTextUsesProtocolDefaultWhenRootDirectoryIsEmpty(t *testing.T) {
|
||||||
|
const relPath = "snapgo/2026/07/image.png"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
protocol string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "FTP", protocol: domain.FTPProtocolFTP, want: "/" + relPath},
|
||||||
|
{name: "SFTP", protocol: domain.FTPProtocolSFTP, want: "~/" + relPath},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := ftpShareText(tt.protocol, "", relPath); got != tt.want {
|
||||||
|
t.Fatalf("ftpShareText() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ type SSHConfig struct {
|
|||||||
// reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP.
|
// reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP.
|
||||||
// - PathPrefix is relative to the account's login root. The application
|
// - PathPrefix is relative to the account's login root. The application
|
||||||
// rejects absolute paths and traversal segments before uploading.
|
// rejects absolute paths and traversal segments before uploading.
|
||||||
|
// - RootDirectory is only used to build the path copied to the clipboard.
|
||||||
|
// Keeping it separate from PathPrefix lets the server expose a login root
|
||||||
|
// such as "~/ftp/" while uploads still target a relative directory such
|
||||||
|
// as "snapgo/".
|
||||||
// - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no
|
// - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no
|
||||||
// host-key mechanism and sends credentials and data without encryption.
|
// host-key mechanism and sends credentials and data without encryption.
|
||||||
type FTPConfig struct {
|
type FTPConfig struct {
|
||||||
@@ -92,6 +96,7 @@ type FTPConfig struct {
|
|||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
AuthMethod string `json:"authMethod"`
|
AuthMethod string `json:"authMethod"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
RootDirectory string `json:"rootDirectory"`
|
||||||
PathPrefix string `json:"pathPrefix"`
|
PathPrefix string `json:"pathPrefix"`
|
||||||
StrictHostKey bool `json:"strictHostKey"`
|
StrictHostKey bool `json:"strictHostKey"`
|
||||||
KnownHostsPath string `json:"knownHostsPath"`
|
KnownHostsPath string `json:"knownHostsPath"`
|
||||||
@@ -260,6 +265,7 @@ func DefaultAppConfig() AppConfig {
|
|||||||
Protocol: FTPProtocolFTP,
|
Protocol: FTPProtocolFTP,
|
||||||
Port: 21,
|
Port: 21,
|
||||||
AuthMethod: SSHAuthPassword,
|
AuthMethod: SSHAuthPassword,
|
||||||
|
RootDirectory: "/",
|
||||||
PathPrefix: "snapgo/",
|
PathPrefix: "snapgo/",
|
||||||
ConnectTimeoutSecs: 10,
|
ConnectTimeoutSecs: 10,
|
||||||
StrictHostKey: false,
|
StrictHostKey: false,
|
||||||
@@ -371,6 +377,13 @@ func (c *AppConfig) Normalize() {
|
|||||||
if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey {
|
if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey {
|
||||||
c.FTP.AuthMethod = SSHAuthPassword
|
c.FTP.AuthMethod = SSHAuthPassword
|
||||||
}
|
}
|
||||||
|
if c.FTP.RootDirectory == "" {
|
||||||
|
if c.FTP.Protocol == FTPProtocolSFTP {
|
||||||
|
c.FTP.RootDirectory = "~/"
|
||||||
|
} else {
|
||||||
|
c.FTP.RootDirectory = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.FTP.PathPrefix == "" {
|
if c.FTP.PathPrefix == "" {
|
||||||
c.FTP.PathPrefix = "snapgo/"
|
c.FTP.PathPrefix = "snapgo/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ func TestDefaultAppConfigIncludesFTPDefaults(t *testing.T) {
|
|||||||
if cfg.FTP.PathPrefix != "snapgo/" {
|
if cfg.FTP.PathPrefix != "snapgo/" {
|
||||||
t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix)
|
t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix)
|
||||||
}
|
}
|
||||||
|
if cfg.FTP.RootDirectory != "/" {
|
||||||
|
t.Fatalf("expected default root directory, got %q", cfg.FTP.RootDirectory)
|
||||||
|
}
|
||||||
if cfg.FTP.ConnectTimeoutSecs != 10 {
|
if cfg.FTP.ConnectTimeoutSecs != 10 {
|
||||||
t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs)
|
t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs)
|
||||||
}
|
}
|
||||||
@@ -31,6 +34,9 @@ func TestNormalizeUsesSFTPDefaultPort(t *testing.T) {
|
|||||||
if cfg.FTP.AuthMethod != SSHAuthPassword {
|
if cfg.FTP.AuthMethod != SSHAuthPassword {
|
||||||
t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod)
|
t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod)
|
||||||
}
|
}
|
||||||
|
if cfg.FTP.RootDirectory != "~/" {
|
||||||
|
t.Fatalf("expected SFTP home root directory, got %q", cfg.FTP.RootDirectory)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) {
|
func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) {
|
||||||
|
|||||||
@@ -838,6 +838,24 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height)));
|
MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Annotations use selection-local coordinates. When a top or left resize
|
||||||
|
// moves the selection origin, offset every local point in the opposite
|
||||||
|
// direction so the annotation remains attached to the same screen pixels.
|
||||||
|
- (void)preserveAnnotationScreenPositionsFromOrigin:(NSPoint)previousOrigin toOrigin:(NSPoint)nextOrigin {
|
||||||
|
CGFloat dx = previousOrigin.x - nextOrigin.x;
|
||||||
|
CGFloat dy = previousOrigin.y - nextOrigin.y;
|
||||||
|
if (fabs(dx) <= 0.001 && fabs(dy) <= 0.001) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (NSDictionary *annotation in _annotations) {
|
||||||
|
NSMutableArray *points = (NSMutableArray *)annotation[@"points"];
|
||||||
|
for (NSUInteger i = 0; i < points.count; i++) {
|
||||||
|
NSPoint point = [points[i] pointValue];
|
||||||
|
points[i] = [NSValue valueWithPoint:NSMakePoint(point.x + dx, point.y + dy)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Translate a selection rectangle expressed in view-local coordinates into
|
// Translate a selection rectangle expressed in view-local coordinates into
|
||||||
// the global top-left screen coordinate system used by
|
// the global top-left screen coordinate system used by
|
||||||
// `/usr/sbin/screencapture -R`.
|
// `/usr/sbin/screencapture -R`.
|
||||||
@@ -992,7 +1010,9 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
right = MAX(0, MIN(right, self.bounds.size.width));
|
right = MAX(0, MIN(right, self.bounds.size.width));
|
||||||
top = MAX(0, MIN(top, self.bounds.size.height));
|
top = MAX(0, MIN(top, self.bounds.size.height));
|
||||||
bottom = MAX(0, MIN(bottom, self.bounds.size.height));
|
bottom = MAX(0, MIN(bottom, self.bounds.size.height));
|
||||||
_selection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top));
|
NSRect nextSelection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top));
|
||||||
|
[self preserveAnnotationScreenPositionsFromOrigin:_selection.origin toOrigin:nextSelection.origin];
|
||||||
|
_selection = nextSelection;
|
||||||
} else if (_draggingTextAnnotation && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) {
|
} else if (_draggingTextAnnotation && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) {
|
||||||
NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex];
|
NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex];
|
||||||
NSMutableArray *points = (NSMutableArray *)item[@"points"];
|
NSMutableArray *points = (NSMutableArray *)item[@"points"];
|
||||||
|
|||||||
Reference in New Issue
Block a user