Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions .agents/skills/localshare-server-deploy/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
name: localshare-server-deploy
description: 在 Linux 主机上安装、配置、升级或排查自托管 LocalShare Server,并验证 systemd、健康检查和一次性配对 key。用于用户要求部署 LocalShare 远程分享 Server;不用于 macOS Client 或纯局域网分享。
license: MIT
metadata:
author: rrbe
version: "1.0.0"
---

# LocalShare Server 部署

部署一个可持久运行的 `localshare-server`,验证公开地址可用,并为 LocalShare Client 生成一次性 Enrollment Key。Server 只转发请求与文件流,不保存 Mac 上的分享内容。

目标环境是使用 systemd 的 Linux,需具备 `curl`、sudo 或 root 权限,以及访问 GitHub Releases 的出站 HTTPS 网络。

## 确认部署边界

- 只操作用户明确指定的主机。用户说“当前服务器”时操作当前 Linux 主机;未指定远程目标时不要猜测 SSH 主机。
- 开始写入前确定公开基地址、监听端口,以及使用 HTTP 还是 Server 直接终止 TLS。公开基地址不能带额外 path 或 query。
- HTTPS 需要域名匹配的证书和私钥路径。不要自行申请证书、配置反向代理或修改 DNS,除非用户明确要求。
- 修改防火墙、覆盖已有 systemd unit、替换已有二进制或调整已有 TLS 文件前,先展示检测结果并取得确认。
- Enrollment Key、Device Token 和 Share Token 不得写入公开日志或代码仓库。

## 只读预检

1. 检查 `uname -s` 为 Linux,并确认 `uname -m`:`x86_64` 映射为 `amd64`,`aarch64` 或 `arm64` 映射为 `arm64`。其他架构停止并报告不支持。
2. 确认 `systemctl`、`curl`、`sha256sum`、`sudo` 或 root 权限可用。
3. 检查目标端口是否被占用,并读取现有的 `localshare-server.service`、`/usr/local/bin/localshare-server` 和 `/var/lib/localshare` 状态。
4. 如果发现已有部署,保留 `/var/lib/localshare/state.json`;在用户确认升级或修复方案之前不要覆盖文件。

## 下载并校验 Release

从 `https://github.com/rrbe/LocalShare/releases/latest` 解析最新 `vX.Y.Z` tag,去掉 `v` 后得到版本号。下载:

```text
LocalShare-Server-<version>-linux-<arch>
LocalShare-Server-<version>-checksums.txt
```

在 `mktemp -d` 创建的临时目录中下载文件,用 checksums 文件和 `sha256sum -c` 校验目标二进制。只有校验通过后,才使用 `sudo install -m 0755` 安装到 `/usr/local/bin/localshare-server`。

如果最新 Release 没有对应 Server 资产,停止并说明情况;可以询问用户是否改为从源码构建,但不要静默下载未经发布的构建。

## 创建运行账户和状态目录

如不存在,创建系统用户:

```bash
sudo useradd --system --home /var/lib/localshare --shell /usr/sbin/nologin localshare
sudo install -d -o localshare -g localshare -m 0700 /var/lib/localshare
```

状态目录必须持久化。不得删除、清空或重新生成已有 `state.json`。保持状态目录权限为 `0700`;Server 会把状态文件和锁文件权限收敛为 `0600`。

## 配置 systemd

使用 `/etc/systemd/system/localshare-server.service`。基础 `ExecStart` 为:

```text
/usr/local/bin/localshare-server serve --listen :<port> --public-url <public-base-url> --state-dir /var/lib/localshare
```

Server 直接终止 TLS 时追加 `--tls-cert <certificate-path> --tls-key <private-key-path>`,并确认 `localshare` 用户可以读取这两个文件。

Service 至少使用:

```ini
[Unit]
Description=LocalShare Server
After=network-online.target
Wants=network-online.target

[Service]
User=localshare
Group=localshare
ExecStart=<resolved command>
Restart=on-failure
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/localshare

[Install]
WantedBy=multi-user.target
```

写入后运行 `systemctl daemon-reload` 和 `systemctl enable --now localshare-server`。

## 验证并生成配对 key

1. 确认 `systemctl is-active localshare-server` 返回 `active`。
2. 请求 `<public-base-url>/healthz`,必须返回 HTTP 200 和包含 `{"status":"ok"}` 的 JSON。
3. 如果失败,查看 `journalctl -u localshare-server --no-pager -n 100`,修复真实原因后重新验证;不要在健康检查失败时生成 key 或宣布完成。
4. 健康检查通过后运行:

```bash
sudo -u localshare /usr/local/bin/localshare-server key create \
--name <device-name> \
--state-dir /var/lib/localshare
```

5. 把一次性 Enrollment Key 只返回给用户,并提醒在 macOS Client 的“设置 → 远程访问”中填写公开地址和 key。

## 交付结果

报告已安装版本和架构、公开地址、监听端口、TLS 模式、systemd 状态、健康检查结果、状态目录,以及 Enrollment Key。明确列出任何没有执行的防火墙、DNS、证书或外部连通性工作。
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,26 @@ on:
pull_request:

jobs:
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.23'
- name: 测试 LocalShare Server
working-directory: server
run: |
go test -race ./...
go vet ./...

smoke:
runs-on: macos-latest # 与 release 同款 runner,自带 Xcode / Swift 工具链与 Node
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.23'

- name: 编译 debug(首次会拉取 Swifter / Sparkle)
run: swift build
Expand Down Expand Up @@ -54,6 +70,18 @@ jobs:
- name: 网页逐请求语言(Accept-Language)
run: ./tools/smoke-accept-language.sh

- name: 远程访问只读策略
run: ./tools/smoke-remote-readonly.sh

- name: 远程访问完整链路
run: ./tools/smoke-remote-e2e.sh

- name: Release 模式远程访问完整链路
run: |
swift build -c release
BIN=.build/release/LocalShare ./tools/smoke-remote-readonly.sh
CLIENT_BIN=.build/release/LocalShare ./tools/smoke-remote-e2e.sh

- name: 传递文本(/ls/text + 转义 + 虚拟根文本行)
run: ./tools/smoke-text.sh

Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Deploy docs

on:
push:
branches:
- master
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

- name: Configure GitHub Pages
id: pages
uses: actions/configure-pages@v6

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build documentation
env:
DOCS_BASE: ${{ steps.pages.outputs.base_path }}
run: pnpm docs:build

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: docs/.vitepress/dist

deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
24 changes: 23 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ jobs:
}
echo "VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"

- uses: actions/setup-go@v7
if: ${{ hashFiles('server/go.mod') != '' }}
with:
go-version-file: server/go.mod

- name: 编译 LocalShare Server(Linux amd64 / arm64)
if: ${{ hashFiles('server/go.mod') != '' }}
working-directory: server
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o "../LocalShare-Server-$VERSION-linux-amd64" .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -trimpath -ldflags "-s -w" -o "../LocalShare-Server-$VERSION-linux-arm64" .
cd ..
shasum -a 256 LocalShare-Server-* > "LocalShare-Server-$VERSION-checksums.txt"

- name: 写入版本号到 Info.plist
run: |
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" bundle/Info.plist
Expand Down Expand Up @@ -94,6 +110,9 @@ jobs:
CHANGELOG="$(git tag -l --format='%(contents)' "$RELEASE_TAG")"
fi
printf '**LocalShare %s** · 局域网文件 / 文件夹只读分享,扫码即看。\nUniversal(Apple Silicon + Intel),要求 macOS 13+。\n' "$VERSION" > release_body.md
if [ -f "LocalShare-Server-$VERSION-linux-amd64" ]; then
printf '\n远程分享 Server 同时提供 Linux amd64 / arm64 单文件二进制及 SHA-256 校验文件。\n' >> release_body.md
fi
if [ -n "$CHANGELOG" ]; then
printf '\n## 更新内容\n\n%s\n' "$CHANGELOG" >> release_body.md
fi
Expand All @@ -115,7 +134,10 @@ jobs:
with:
tag_name: ${{ env.RELEASE_TAG }}
name: LocalShare ${{ env.VERSION }}
files: LocalShare-${{ env.VERSION }}.dmg
files: |
LocalShare-${{ env.VERSION }}.dmg
LocalShare-Server-${{ env.VERSION }}-linux-*
LocalShare-Server-${{ env.VERSION }}-checksums.txt
body_path: release_body.md

- name: EdDSA 签名 DMG + 生成 appcast.xml 挂到 Release
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.build/
.swiftpm/
/server/localshare-server
dist/
*.app
.DS_Store
Expand All @@ -12,3 +13,6 @@ private_key.txt
.claude/
.codex/
.worktrees/
node_modules/
docs/.vitepress/cache/
docs/.vitepress/dist/
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ Release flow:
- **Threading model**: Swifter callbacks run on socket threads, while `AppState` is `@MainActor`. Shared mutable server state is protected by one `NSLock`: `share`, `token`, `uploadEnabled`, `lastSeen`, `nameCache`, and `nameLookupInFlight`.
- **Share changes do not restart the server** when the port is unchanged, but they rotate tokens immediately. Change the key before changing content.
- **`.raw` has no keep-alive and unknown body length**, so file responses must write `Content-Length`.
- **Remote mount prefix**: generated HTML must use relative navigation/heartbeat endpoints so it works under both `/`
and `/share/<token>/`; remote requests must carry the registered `share_id`, and the Client must reject stale IDs
before forwarding to `FileServer`.

## Design Constraint

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[简体中文](README_CN.md) | English

A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi — the other end just uses a browser, with no client to install.
A small macOS app that moves files and text between your Mac and other devices (phone, tablet…) over WiFi, with optional browser-only remote sharing — the other end just uses a browser, with no client to install.

- Pick a file or folder, scan the QR code that appears (or just open the link), and browse the files in your phone's browser (HTML, PDF, Markdown, images…)
- Two-way text transfer — type some text and share it via a QR code, or send text back from the phone to the Mac
Expand All @@ -22,6 +22,7 @@ A small macOS app that moves files and text between your Mac and other devices (
- After you share, the other end can send files and text back
- Shows who's currently viewing
- `localshare` command line: bring up the window to share, or `--headless` to print the link and QR code in the terminal
- Optional browser-only remote access through a self-hosted LocalShare Server; remote shares are read-only

## Why this app exists

Expand All @@ -40,6 +41,10 @@ The QR code points to something like `http://192.168.x.x:8080/?t=<random-token>`

> ⚠️ Traffic is plain HTTP (unencrypted). Best avoided on public networks like airports and cafés.

### Remote browser sharing

Run the self-contained [`localshare-server`](server/README.md), create a one-time Enrollment Key, then enter the Server address and key in Settings. After pairing, the app remembers the device credential in Keychain; active shares use Connect/Disconnect and the receiving device only needs a browser. See the [remote-sharing plan](docs/REMOTE_SHARING_PLAN.md).

## Terminal usage

In Settings → Command-Line Tool, click "Install". After that you can share from the terminal in one command:
Expand Down
7 changes: 6 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

简体中文 | [English](README.md)

一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,对端使用浏览器,无须安装客户端。
一个 macOS 小工具,在本机和手机等设备之间通过 WiFi 互通文件和文本,也支持可选的纯浏览器跨网络分享,对端无须安装客户端。

- 选中文件或文件夹,相机扫描出现的二维码,或直接打开链接,在手机浏览器里浏览这些文件(HTML、PDF、Markdown、图片……)
- 支持双向传送文本,编辑文字通过二维码分享后传输,并可从手机端发送文本回 Mac
Expand All @@ -22,6 +22,7 @@
- 支持分享后,由对端反向传输文件、文本
- 显示当前在线访客
- 命令行 `localshare` 命令:唤起窗口分享,或 `--headless` 在终端显示链接和二维码
- 可选通过自托管 LocalShare Server 远程访问浏览器;远程分享只读

## 为什么有这个 app

Expand All @@ -40,6 +41,10 @@

> ⚠️ 传输是明文 HTTP(没有加密)。机场咖啡厅等公共网络下最好不要使用。

### 远程浏览器分享

运行自包含的 [`localshare-server`](server/README.md),生成一次性 Enrollment Key,在「设置」里填写 Server 地址和 key。配对后设备凭据会保存到 Keychain;正在分享时只需点击 Connect/Disconnect,对端设备只需浏览器。详见[远程分享计划](docs/REMOTE_SHARING_PLAN.md)。

## 终端用法

在「设置 → 命令行工具」里点「安装」,之后可以在终端一键分享:
Expand Down
4 changes: 4 additions & 0 deletions Sources/LocalShare/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
}

func applicationWillTerminate(_ notification: Notification) {
AppState.shared?.stop()
}

// 关窗不退出:菜单栏图标常驻,服务继续广播;退出走菜单栏「退出」或 ⌘Q。
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
Expand Down
Loading
Loading