Compare commits
4 commits
8d2c6ca504
...
1eff4656bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eff4656bd | |||
| 881f0c1f48 | |||
| 099a050eee | |||
| 888d2ee71f |
17 changed files with 69 additions and 334 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -9,3 +9,8 @@
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
/receiver/receiver
|
/receiver/receiver
|
||||||
|
|
||||||
|
# Never commit signing secrets
|
||||||
|
/keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
|
||||||
12
LICENSE
Normal file
12
LICENSE
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
Copyright (c) 2026 Lily Goscha, All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
|
@ -5,17 +7,36 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.filedrop"
|
namespace = "com.bllry.filedrop"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.example.filedrop"
|
applicationId = "com.bllry.filedrop"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release signing is enabled only when keystore.properties is present (local
|
||||||
|
// release builds). F-Droid's build server has no such file, so it builds
|
||||||
|
// unsigned; F-Droid then verifies the developer-published APK reproduces.
|
||||||
|
// Signing via the Gradle build (not external apksigner) keeps the APK's zip
|
||||||
|
// layout identical to the unsigned build, which is required for apksigcopier.
|
||||||
|
val keystorePropsFile = rootProject.file("keystore.properties")
|
||||||
|
val releaseSigning = if (keystorePropsFile.exists()) {
|
||||||
|
val props = Properties()
|
||||||
|
keystorePropsFile.inputStream().use { props.load(it) }
|
||||||
|
signingConfigs.create("release") {
|
||||||
|
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||||
|
storePassword = props.getProperty("storePassword")
|
||||||
|
keyAlias = props.getProperty("keyAlias")
|
||||||
|
keyPassword = props.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
|
@ -23,6 +44,7 @@ android {
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
signingConfig = releaseSigning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.example.filedrop
|
package com.bllry.filedrop
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.example.filedrop
|
package com.bllry.filedrop
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.example.filedrop
|
package com.bllry.filedrop
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.example.filedrop
|
package com.bllry.filedrop
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
21
fastlane/metadata/android/en-US/full_description.txt
Normal file
21
fastlane/metadata/android/en-US/full_description.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
File Drop is a minimal tool for sending files, photos, and videos from your
|
||||||
|
Android phone to a PC on the same local network — no cloud, no accounts, no
|
||||||
|
sign-in.
|
||||||
|
|
||||||
|
Enter your PC's LAN address once (for example 192.168.1.42:8787) and an
|
||||||
|
optional shared token, then tap "Pick files" and choose what to send. Each file
|
||||||
|
streams straight to a small receiver program running on your PC, with a progress
|
||||||
|
bar per file. Large videos stream without loading into memory.
|
||||||
|
|
||||||
|
Everything stays on your own network. The app only needs the INTERNET
|
||||||
|
permission to reach the PC over Wi-Fi; it uses no Google services and no
|
||||||
|
tracking.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
* Send any file type using the system file picker (no storage permission needed)
|
||||||
|
* Per-file upload progress and retry on failure
|
||||||
|
* Optional shared-secret token so only you can drop files onto your PC
|
||||||
|
* Settings saved locally on the device
|
||||||
|
|
||||||
|
You must run the companion receiver on your PC for the app to have somewhere to
|
||||||
|
send files.
|
||||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Send files and photos from your phone to a PC on the same Wi-Fi.
|
||||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
1
fastlane/metadata/android/en-US/title.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
File Drop
|
||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|
|
||||||
99
plan.md
99
plan.md
|
|
@ -1,99 +0,0 @@
|
||||||
# Android → PC File Drop
|
|
||||||
|
|
||||||
Minimal Kotlin Android app that picks files/images and uploads them to a folder on a PC over the local network.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Two pieces:
|
|
||||||
|
|
||||||
1. **PC receiver** — tiny HTTP server that accepts multipart uploads and writes files into a chosen folder.
|
|
||||||
2. **Android app** — one screen, one button: pick files → POST them to the PC.
|
|
||||||
|
|
||||||
No cloud, no accounts, no discovery protocol. You type (or QR-scan) the PC's LAN IP once and save it.
|
|
||||||
|
|
||||||
## PC Receiver
|
|
||||||
|
|
||||||
**Language:** Go (single binary). Go for "double-click and forget."
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- Listen on `0.0.0.0:8787`.
|
|
||||||
- `POST /upload` accepts `multipart/form-data`, saves each part to `DROP_DIR` with its original filename.
|
|
||||||
- Collision policy: append ` (1)`, ` (2)`, etc.
|
|
||||||
- Optional shared-secret header (`X-Auth: <token>`) so randos on the same Wi-Fi can't dump files on you.
|
|
||||||
- `GET /` returns "ok" for a health check.
|
|
||||||
- Log each received file: name, size, source IP.
|
|
||||||
|
|
||||||
it is seen on the local panel so that user locally can download
|
|
||||||
|
|
||||||
**Config:**
|
|
||||||
- `DROP_DIR` — where files land (default `~/Drops`).
|
|
||||||
- `AUTH_TOKEN` — shared secret (generated on first run, printed + saved to config).
|
|
||||||
- `PORT` — default 8787.
|
|
||||||
|
|
||||||
**Firewall:** open the port on the PC's LAN profile only.
|
|
||||||
|
|
||||||
## Android App
|
|
||||||
|
|
||||||
**Stack:** Kotlin, Jetpack Compose, single Activity, minSdk 26.
|
|
||||||
|
|
||||||
**Screens:** one.
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
- Text field: PC address (e.g. `192.168.1.42:8787`), persisted in `DataStore`.
|
|
||||||
- Text field: auth token, persisted.
|
|
||||||
- Big button: "Pick files".
|
|
||||||
- Below: list of currently-selected files with progress bars during upload.
|
|
||||||
- Snackbar for success/failure per file.
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. Tap button → `ActivityResultContracts.OpenMultipleDocuments()` (handles images, video, any file; no storage permission needed on modern Android).
|
|
||||||
2. For each returned `Uri`: stream it as a multipart part to `http://<address>/upload` with the auth header.
|
|
||||||
3. Show per-file progress; on 200, mark done; on error, show retry.
|
|
||||||
|
|
||||||
**Networking:** OkHttp with a `MultipartBody`, streaming from `contentResolver.openInputStream(uri)` so big videos don't OOM.
|
|
||||||
|
|
||||||
**Manifest:**
|
|
||||||
- `INTERNET` permission.
|
|
||||||
- `usesCleartextTraffic="true"` scoped to local IP ranges via `network_security_config.xml` (HTTP is fine on LAN; adding TLS is a stretch goal).
|
|
||||||
|
|
||||||
**Share target (stretch):** register as a share target so you can hit "Share → Drop to PC" from the Photos app.
|
|
||||||
|
|
||||||
## Protocol
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /upload HTTP/1.1
|
|
||||||
Host: 192.168.1.42:8787
|
|
||||||
X-Auth: <token>
|
|
||||||
Content-Type: multipart/form-data; boundary=...
|
|
||||||
|
|
||||||
--boundary
|
|
||||||
Content-Disposition: form-data; name="file"; filename="IMG_1234.jpg"
|
|
||||||
Content-Type: image/jpeg
|
|
||||||
|
|
||||||
<bytes>
|
|
||||||
--boundary--
|
|
||||||
```
|
|
||||||
|
|
||||||
Response: `200 OK` with JSON `{"saved": "IMG_1234.jpg"}` or `4xx/5xx` with error text.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
1. PC receiver in ~60 lines; test with `curl -F file=@foo.jpg http://localhost:8787/upload -H "X-Auth: ..."`.
|
|
||||||
2. Android app scaffold: Compose UI, DataStore for settings, file picker returning URIs.
|
|
||||||
3. Wire up OkHttp multipart upload from a URI stream.
|
|
||||||
4. Progress + error UI.
|
|
||||||
5. Package: PC receiver as a systemd user service (Linux) or Task Scheduler entry (Windows) so it autostarts. Android side sideload the APK.
|
|
||||||
|
|
||||||
## Stretch
|
|
||||||
|
|
||||||
- QR code on PC receiver's `GET /` page encoding `address + token` so first-time setup is scan-once.
|
|
||||||
- mDNS advertisement (`_filedrop._tcp.local`) so the app can discover the PC without typing IPs.
|
|
||||||
- TLS with a self-signed cert pinned in the app.
|
|
||||||
- Share-target intent filter.
|
|
||||||
- Resume interrupted uploads (`Content-Range`).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- PC → Android direction (use MTP or a second app instance later).
|
|
||||||
- Internet / NAT traversal.
|
|
||||||
- Multiple PCs at once (one address at a time is fine).
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module file-share/receiver
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
225
receiver/main.go
225
receiver/main.go
|
|
@ -1,225 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"html"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
cfgDir := filepath.Join(home, ".file-share")
|
|
||||||
_ = os.MkdirAll(cfgDir, 0o755)
|
|
||||||
cfgPath := filepath.Join(cfgDir, "receiver.env")
|
|
||||||
|
|
||||||
dropDir := env("DROP_DIR", filepath.Join(home, "Drops"))
|
|
||||||
port := env("PORT", "8787")
|
|
||||||
host := env("HOST", lanAddress())
|
|
||||||
authToken := env("AUTH_TOKEN", "")
|
|
||||||
if authToken == "" {
|
|
||||||
authToken = loadOrGenerateToken(cfgPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.MkdirAll(dropDir, 0o755)
|
|
||||||
log.Printf("drop dir: %s", dropDir)
|
|
||||||
log.Printf("auth token: %s", authToken)
|
|
||||||
log.Printf("listening on http://%s:%s", host, port)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprint(w, "ok")
|
|
||||||
})
|
|
||||||
mux.HandleFunc("POST /upload", handleUpload(dropDir, authToken))
|
|
||||||
mux.HandleFunc("GET /files", handleList(dropDir))
|
|
||||||
mux.HandleFunc("GET /download", handleDownload(dropDir))
|
|
||||||
|
|
||||||
log.Fatal(http.ListenAndServe(host+":"+port, mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleUpload(dropDir, authToken string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if authToken != "" && r.Header.Get("X-Auth") != authToken {
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
files := r.MultipartForm.File["file"]
|
|
||||||
if len(files) == 0 {
|
|
||||||
http.Error(w, "no file", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type savedFile struct {
|
|
||||||
Saved string `json:"saved"`
|
|
||||||
}
|
|
||||||
saved := make([]savedFile, 0, len(files))
|
|
||||||
for _, fh := range files {
|
|
||||||
src, err := fh.Open()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dest := uniquePath(dropDir, filepath.Base(fh.Filename))
|
|
||||||
dst, err := os.Create(dest)
|
|
||||||
if err != nil {
|
|
||||||
_ = src.Close()
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
n, err := io.Copy(dst, src)
|
|
||||||
_ = src.Close()
|
|
||||||
_ = dst.Close()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := filepath.Base(dest)
|
|
||||||
log.Printf("saved %s (%d bytes) from %s", name, n, r.RemoteAddr)
|
|
||||||
saved = append(saved, savedFile{Saved: name})
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if len(saved) == 1 {
|
|
||||||
fmt.Fprintf(w, `{"saved":%q}`, saved[0].Saved)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprint(w, "[")
|
|
||||||
for i, s := range saved {
|
|
||||||
if i > 0 {
|
|
||||||
fmt.Fprint(w, ",")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, `{"saved":%q}`, s.Saved)
|
|
||||||
}
|
|
||||||
fmt.Fprint(w, "]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleList(dropDir string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
entries, err := os.ReadDir(dropDir)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprint(w, `<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>Drops</title></head><body><h1>Drops</h1><ul>`)
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, _ := e.Info()
|
|
||||||
name := e.Name()
|
|
||||||
fmt.Fprintf(w, `<li><a href="/download?name=%s">%s</a> (%s)</li>`,
|
|
||||||
url.QueryEscape(name), html.EscapeString(name), formatBytes(info.Size()))
|
|
||||||
}
|
|
||||||
fmt.Fprint(w, `</ul></body></html>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleDownload(dropDir string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
name := r.URL.Query().Get("name")
|
|
||||||
if name == "" {
|
|
||||||
http.Error(w, "missing name", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path := filepath.Join(dropDir, filepath.Base(name))
|
|
||||||
http.ServeFile(w, r, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniquePath(dir, filename string) string {
|
|
||||||
if filename == "" {
|
|
||||||
filename = "unnamed"
|
|
||||||
}
|
|
||||||
ext := filepath.Ext(filename)
|
|
||||||
base := strings.TrimSuffix(filename, ext)
|
|
||||||
dest := filepath.Join(dir, filename)
|
|
||||||
if _, err := os.Stat(dest); os.IsNotExist(err) {
|
|
||||||
return dest
|
|
||||||
}
|
|
||||||
for i := 1; ; i++ {
|
|
||||||
candidate := filepath.Join(dir, base+" ("+strconv.Itoa(i)+")"+ext)
|
|
||||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOrGenerateToken(path string) string {
|
|
||||||
if data, err := os.ReadFile(path); err == nil {
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
if strings.HasPrefix(line, "AUTH_TOKEN=") {
|
|
||||||
return strings.TrimPrefix(line, "AUTH_TOKEN=")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b := make([]byte, 4)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
token := hex.EncodeToString(b)
|
|
||||||
_ = os.WriteFile(path, []byte("AUTH_TOKEN="+token+"\n"), 0o600)
|
|
||||||
log.Printf("generated token, saved to %s", path)
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
func lanAddress() string {
|
|
||||||
addrs, err := net.InterfaceAddrs()
|
|
||||||
if err != nil {
|
|
||||||
return "127.0.0.1"
|
|
||||||
}
|
|
||||||
var fallback string
|
|
||||||
for _, a := range addrs {
|
|
||||||
ipnet, ok := a.(*net.IPNet)
|
|
||||||
if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ip := ipnet.IP.To4()
|
|
||||||
if ip[0] == 192 && ip[1] == 168 {
|
|
||||||
return ip.String()
|
|
||||||
}
|
|
||||||
if fallback == "" && (ip[0] == 10 || (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31)) {
|
|
||||||
fallback = ip.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fallback != "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return "127.0.0.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
func env(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatBytes(n int64) string {
|
|
||||||
if n < 1024 {
|
|
||||||
return strconv.FormatInt(n, 10) + " B"
|
|
||||||
}
|
|
||||||
units := []string{"KB", "MB", "GB", "TB"}
|
|
||||||
f := float64(n) / 1024
|
|
||||||
for _, u := range units {
|
|
||||||
if f < 1024 || u == units[len(units)-1] {
|
|
||||||
return fmt.Sprintf("%.1f %s", f, u)
|
|
||||||
}
|
|
||||||
f /= 1024
|
|
||||||
}
|
|
||||||
return strconv.FormatInt(n, 10) + " B"
|
|
||||||
}
|
|
||||||
Binary file not shown.
Loading…
Add table
Reference in a new issue