feat: enhance mobile voice build and UI layout
- Updated AGENTS.md with new build commands for development and production. - Added a new script in package.json for starting Expo with a specific hostname. - Improved layout handling in DictationScreen by adding dynamic height calculations for dropdown menus and footers. - Introduced new state variables to manage menu list heights and footer heights for better UI responsiveness. - Enhanced QR code generation for mobile pairing in the serve command, allowing for a connect QR option without starting the server.pull/19545/head
parent
c90640e0e1
commit
4d30ad1e7c
|
|
@ -175,3 +175,9 @@ Example shape:
|
||||||
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
|
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
|
||||||
- Confirm no accidental config side effects were introduced.
|
- Confirm no accidental config side effects were introduced.
|
||||||
- Summarize what was verified on-device vs only in tooling.
|
- Summarize what was verified on-device vs only in tooling.
|
||||||
|
|
||||||
|
|
||||||
|
- Dev build (internal/dev client):
|
||||||
|
- bunx eas build --profile development --platform ios
|
||||||
|
- Production build + auto-submit:
|
||||||
|
- bunx eas build --profile production --platform ios --auto-submit
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
|
"expo:start": "REACT_NATIVE_PACKAGER_HOSTNAME=exos.husky-tilapia.ts.net expo start --dev-client --clear --host lan",
|
||||||
"relay": "echo 'Use packages/apn-relay for APNs relay server'",
|
"relay": "echo 'Use packages/apn-relay for APNs relay server'",
|
||||||
"relay:legacy": "node ./relay/opencode-relay.mjs",
|
"relay:legacy": "node ./relay/opencode-relay.mjs",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ const WAVEFORM_ROWS = 5
|
||||||
const WAVEFORM_CELL_SIZE = 8
|
const WAVEFORM_CELL_SIZE = 8
|
||||||
const WAVEFORM_CELL_GAP = 2
|
const WAVEFORM_CELL_GAP = 2
|
||||||
const DROPDOWN_VISIBLE_ROWS = 6
|
const DROPDOWN_VISIBLE_ROWS = 6
|
||||||
|
const DROPDOWN_ROW_HEIGHT = 42
|
||||||
|
const SERVER_MENU_SECTION_HEIGHT = 56
|
||||||
|
const SERVER_MENU_ENTRY_HEIGHT = 36
|
||||||
|
const SERVER_MENU_FOOTER_HEIGHT = 28
|
||||||
// If the press duration is shorter than this, treat it as a tap (toggle)
|
// If the press duration is shorter than this, treat it as a tap (toggle)
|
||||||
const TAP_THRESHOLD_MS = 300
|
const TAP_THRESHOLD_MS = 300
|
||||||
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
|
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
|
||||||
|
|
@ -591,6 +595,10 @@ export default function DictationScreen() {
|
||||||
const [readerModeRendered, setReaderModeRendered] = useState(false)
|
const [readerModeRendered, setReaderModeRendered] = useState(false)
|
||||||
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
|
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
|
||||||
const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server")
|
const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server")
|
||||||
|
const [serverMenuListHeight, setServerMenuListHeight] = useState(0)
|
||||||
|
const [sessionMenuListHeight, setSessionMenuListHeight] = useState(0)
|
||||||
|
const [serverMenuFooterHeight, setServerMenuFooterHeight] = useState(0)
|
||||||
|
const [sessionMenuFooterHeight, setSessionMenuFooterHeight] = useState(0)
|
||||||
const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null)
|
const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null)
|
||||||
const [scanOpen, setScanOpen] = useState(false)
|
const [scanOpen, setScanOpen] = useState(false)
|
||||||
const [pairSelectionOpen, setPairSelectionOpen] = useState(false)
|
const [pairSelectionOpen, setPairSelectionOpen] = useState(false)
|
||||||
|
|
@ -633,6 +641,8 @@ export default function DictationScreen() {
|
||||||
setDropdownMode("none")
|
setDropdownMode("none")
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const discoveryEnabled = onboardingComplete && localNetworkPermissionState !== "denied" && dropdownMode === "server"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
servers,
|
servers,
|
||||||
serversRef,
|
serversRef,
|
||||||
|
|
@ -655,7 +665,7 @@ export default function DictationScreen() {
|
||||||
|
|
||||||
const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
|
const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
|
||||||
{
|
{
|
||||||
enabled: onboardingComplete && localNetworkPermissionState !== "denied",
|
enabled: discoveryEnabled,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2000,17 +2010,29 @@ export default function DictationScreen() {
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
|
const maxDropdownListHeight = DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT
|
||||||
const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1)
|
const serverMenuEntries = Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
|
||||||
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
|
const estimatedServerMenuRowsHeight = Math.min(
|
||||||
|
SERVER_MENU_SECTION_HEIGHT + serverMenuEntries * SERVER_MENU_ENTRY_HEIGHT,
|
||||||
|
maxDropdownListHeight,
|
||||||
|
)
|
||||||
|
const sessionMenuRows = Math.max(activeServer?.sessions.length ?? 0, 1)
|
||||||
|
const estimatedSessionMenuRowsHeight = Math.min(sessionMenuRows, DROPDOWN_VISIBLE_ROWS) * DROPDOWN_ROW_HEIGHT
|
||||||
|
const serverMenuRowsHeight = Math.min(serverMenuListHeight || estimatedServerMenuRowsHeight, maxDropdownListHeight)
|
||||||
|
const sessionMenuRowsHeight = Math.min(sessionMenuListHeight || estimatedSessionMenuRowsHeight, maxDropdownListHeight)
|
||||||
|
const expandedRowsHeight = effectiveDropdownMode === "server" ? serverMenuRowsHeight : sessionMenuRowsHeight
|
||||||
|
|
||||||
|
const estimatedSessionFooterHeight = sessionCreationChoiceCount === 2 ? 72 : sessionCreationChoiceCount === 1 ? 38 : 8
|
||||||
|
|
||||||
|
const measuredServerFooterHeight = serverMenuFooterHeight || SERVER_MENU_FOOTER_HEIGHT
|
||||||
|
const measuredSessionFooterHeight = sessionMenuFooterHeight || estimatedSessionFooterHeight
|
||||||
|
|
||||||
const dropdownFooterExtraHeight =
|
const dropdownFooterExtraHeight =
|
||||||
effectiveDropdownMode === "server"
|
effectiveDropdownMode === "server"
|
||||||
? 38
|
? measuredServerFooterHeight
|
||||||
: sessionCreationChoiceCount === 2
|
: showSessionCreationChoices
|
||||||
? 72
|
? measuredSessionFooterHeight
|
||||||
: sessionCreationChoiceCount === 1
|
: 8
|
||||||
? 38
|
|
||||||
: 8
|
|
||||||
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
|
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
|
||||||
|
|
||||||
const animatedHeaderStyle = useAnimatedStyle(() => ({
|
const animatedHeaderStyle = useAnimatedStyle(() => ({
|
||||||
|
|
@ -2104,6 +2126,26 @@ export default function DictationScreen() {
|
||||||
setWaveformLevels(next)
|
setWaveformLevels(next)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleServerMenuListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||||
|
setServerMenuListHeight((prev) => (prev === next ? prev : next))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSessionMenuListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||||
|
setSessionMenuListHeight((prev) => (prev === next ? prev : next))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleServerMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||||
|
setServerMenuFooterHeight((prev) => (prev === next ? prev : next))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSessionMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||||
|
setSessionMenuFooterHeight((prev) => (prev === next ? prev : next))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleServerMenu = useCallback(() => {
|
const toggleServerMenu = useCallback(() => {
|
||||||
void Haptics.selectionAsync().catch(() => {})
|
void Haptics.selectionAsync().catch(() => {})
|
||||||
setDropdownMode((prev) => {
|
setDropdownMode((prev) => {
|
||||||
|
|
@ -2764,7 +2806,7 @@ export default function DictationScreen() {
|
||||||
bounces={false}
|
bounces={false}
|
||||||
>
|
>
|
||||||
{effectiveDropdownMode === "server" ? (
|
{effectiveDropdownMode === "server" ? (
|
||||||
<>
|
<View onLayout={handleServerMenuListLayout}>
|
||||||
<Text style={styles.serverGroupLabel}>Saved:</Text>
|
<Text style={styles.serverGroupLabel}>Saved:</Text>
|
||||||
|
|
||||||
{servers.length === 0 ? (
|
{servers.length === 0 ? (
|
||||||
|
|
@ -2833,9 +2875,9 @@ export default function DictationScreen() {
|
||||||
{discoveryError}
|
{discoveryError}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</View>
|
||||||
) : activeServer ? (
|
) : activeServer ? (
|
||||||
<>
|
<View onLayout={handleSessionMenuListLayout}>
|
||||||
{activeSession ? (
|
{activeSession ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.currentSessionSummary}>
|
<View style={styles.currentSessionSummary}>
|
||||||
|
|
@ -2890,18 +2932,20 @@ export default function DictationScreen() {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.serverEmptyText}>Select a server first</Text>
|
<Text style={styles.serverEmptyText}>Select a server first</Text>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{effectiveDropdownMode === "server" ? (
|
{effectiveDropdownMode === "server" ? (
|
||||||
<Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
|
<View onLayout={handleServerMenuFooterLayout}>
|
||||||
<Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
|
<Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
|
||||||
</Pressable>
|
<Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? (
|
) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? (
|
||||||
<View style={styles.sessionMenuActions}>
|
<View style={styles.sessionMenuActions} onLayout={handleSessionMenuFooterLayout}>
|
||||||
{activeSession ? (
|
{activeSession ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleCreateSessionLikeCurrent}
|
onPress={handleCreateSessionLikeCurrent}
|
||||||
|
|
@ -3887,14 +3931,14 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
serverMenuInline: {
|
serverMenuInline: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
paddingBottom: 8,
|
paddingBottom: 2,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
dropdownListViewport: {
|
dropdownListViewport: {
|
||||||
maxHeight: DROPDOWN_VISIBLE_ROWS * 42,
|
maxHeight: DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT,
|
||||||
},
|
},
|
||||||
dropdownListContent: {
|
dropdownListContent: {
|
||||||
paddingBottom: 2,
|
paddingBottom: 0,
|
||||||
},
|
},
|
||||||
currentSessionSummary: {
|
currentSessionSummary: {
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
|
|
@ -4026,10 +4070,11 @@ const styles = StyleSheet.create({
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
addServerButton: {
|
addServerButton: {
|
||||||
marginTop: 10,
|
marginTop: 4,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 6,
|
paddingTop: 2,
|
||||||
|
paddingBottom: 10,
|
||||||
},
|
},
|
||||||
addServerButtonText: {
|
addServerButtonText: {
|
||||||
color: "#B8BDC9",
|
color: "#B8BDC9",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ import * as QRCode from "qrcode"
|
||||||
|
|
||||||
const log = Log.create({ service: "serve" })
|
const log = Log.create({ service: "serve" })
|
||||||
|
|
||||||
|
type PairPayload = {
|
||||||
|
v: 1
|
||||||
|
serverID?: string
|
||||||
|
relayURL: string
|
||||||
|
relaySecret: string
|
||||||
|
hosts: string[]
|
||||||
|
}
|
||||||
|
|
||||||
function ipTier(address: string): number {
|
function ipTier(address: string): number {
|
||||||
const parts = address.split(".")
|
const parts = address.split(".")
|
||||||
if (parts.length !== 4) return 4
|
if (parts.length !== 4) return 4
|
||||||
|
|
@ -36,9 +44,10 @@ function advertiseURL(input: string, port: number): string | undefined {
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
|
const hasScheme = raw.includes("://")
|
||||||
|
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
|
||||||
if (!parsed.hostname) return
|
if (!parsed.hostname) return
|
||||||
if (!parsed.port) {
|
if (!parsed.port && !hasScheme) {
|
||||||
parsed.port = String(port)
|
parsed.port = String(port)
|
||||||
}
|
}
|
||||||
return norm(`${parsed.protocol}//${parsed.host}`)
|
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||||
|
|
@ -47,7 +56,7 @@ function advertiseURL(input: string, port: number): string | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hosts(hostname: string, port: number, advertised: string[] = []) {
|
function hosts(hostname: string, port: number, advertised: string[] = [], includeLocal = true) {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const preferred: string[] = []
|
const preferred: string[] = []
|
||||||
const entries: Array<{ url: string; tier: number }> = []
|
const entries: Array<{ url: string; tier: number }> = []
|
||||||
|
|
@ -72,12 +81,15 @@ function hosts(hostname: string, port: number, advertised: string[] = []) {
|
||||||
|
|
||||||
advertised.forEach(addPreferred)
|
advertised.forEach(addPreferred)
|
||||||
|
|
||||||
add(hostname)
|
if (includeLocal) {
|
||||||
Object.values(os.networkInterfaces())
|
add(hostname)
|
||||||
.flatMap((item) => item ?? [])
|
Object.values(os.networkInterfaces())
|
||||||
.filter((item) => item.family === "IPv4" && !item.internal)
|
.flatMap((item) => item ?? [])
|
||||||
.map((item) => item.address)
|
.filter((item) => item.family === "IPv4" && !item.internal)
|
||||||
.forEach(add)
|
.map((item) => item.address)
|
||||||
|
.forEach(add)
|
||||||
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => a.tier - b.tier)
|
entries.sort((a, b) => a.tier - b.tier)
|
||||||
return [...preferred, ...entries.map((item) => item.url)]
|
return [...preferred, ...entries.map((item) => item.url)]
|
||||||
}
|
}
|
||||||
|
|
@ -86,11 +98,39 @@ function pairLink(pair: unknown) {
|
||||||
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}`
|
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pairServerID(input: { relayURL: string; relaySecret: string }) {
|
||||||
|
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
function secretHash(input: string) {
|
function secretHash(input: string) {
|
||||||
if (!input) return "none"
|
if (!input) return "none"
|
||||||
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
|
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function printPairQR(pair: PairPayload) {
|
||||||
|
const link = pairLink(pair)
|
||||||
|
const qrConfig = {
|
||||||
|
type: "terminal" as const,
|
||||||
|
small: true,
|
||||||
|
errorCorrectionLevel: "M" as const,
|
||||||
|
}
|
||||||
|
log.info("pair qr", {
|
||||||
|
relayURL: pair.relayURL,
|
||||||
|
relaySecretHash: secretHash(pair.relaySecret),
|
||||||
|
serverID: pair.serverID,
|
||||||
|
hosts: pair.hosts,
|
||||||
|
hostCount: pair.hosts.length,
|
||||||
|
hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")),
|
||||||
|
linkLength: link.length,
|
||||||
|
qr: qrConfig,
|
||||||
|
})
|
||||||
|
const code = await QRCode.toString(link, {
|
||||||
|
...qrConfig,
|
||||||
|
})
|
||||||
|
console.log("scan qr code in mobile app or phone camera")
|
||||||
|
console.log(code)
|
||||||
|
}
|
||||||
|
|
||||||
export const ServeCommand = cmd({
|
export const ServeCommand = cmd({
|
||||||
command: "serve",
|
command: "serve",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
|
|
@ -107,16 +147,15 @@ export const ServeCommand = cmd({
|
||||||
type: "string",
|
type: "string",
|
||||||
array: true,
|
array: true,
|
||||||
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
|
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
|
||||||
|
})
|
||||||
|
.option("connect-qr", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
describe: "print mobile connect QR and exit without starting the server",
|
||||||
}),
|
}),
|
||||||
describe: "starts a headless opencode server",
|
describe: "starts a headless opencode server",
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
|
||||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
|
||||||
}
|
|
||||||
const opts = await resolveNetworkOptions(args)
|
const opts = await resolveNetworkOptions(args)
|
||||||
const server = Server.listen(opts)
|
|
||||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
|
||||||
|
|
||||||
const relayURL = (
|
const relayURL = (
|
||||||
args["relay-url"] ??
|
args["relay-url"] ??
|
||||||
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
||||||
|
|
@ -136,6 +175,40 @@ export const ServeCommand = cmd({
|
||||||
|
|
||||||
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
||||||
const relaySecret = input || randomBytes(18).toString("base64url")
|
const relaySecret = input || randomBytes(18).toString("base64url")
|
||||||
|
const connectQR = Boolean(args["connect-qr"])
|
||||||
|
|
||||||
|
if (connectQR) {
|
||||||
|
const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
|
||||||
|
if (!pairHosts.length) {
|
||||||
|
console.log("connect qr mode requires at least one valid --advertise-host value")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
console.log("experimental push relay secret generated")
|
||||||
|
console.log(
|
||||||
|
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("printing connect qr without starting the server")
|
||||||
|
await printPairQR({
|
||||||
|
v: 1,
|
||||||
|
serverID: pairServerID({ relayURL, relaySecret }),
|
||||||
|
relayURL,
|
||||||
|
relaySecret,
|
||||||
|
hosts: pairHosts,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||||
|
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Server.listen(opts)
|
||||||
|
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
console.log("experimental push relay secret generated")
|
console.log("experimental push relay secret generated")
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -155,6 +228,7 @@ export const ServeCommand = cmd({
|
||||||
const pair = started ??
|
const pair = started ??
|
||||||
PushRelay.pair() ?? {
|
PushRelay.pair() ?? {
|
||||||
v: 1 as const,
|
v: 1 as const,
|
||||||
|
serverID: pairServerID({ relayURL, relaySecret }),
|
||||||
relayURL,
|
relayURL,
|
||||||
relaySecret,
|
relaySecret,
|
||||||
hosts: hosts(host, port, advertiseHosts),
|
hosts: hosts(host, port, advertiseHosts),
|
||||||
|
|
@ -164,27 +238,7 @@ export const ServeCommand = cmd({
|
||||||
}
|
}
|
||||||
if (pair) {
|
if (pair) {
|
||||||
console.log("experimental push relay enabled")
|
console.log("experimental push relay enabled")
|
||||||
const link = pairLink(pair)
|
await printPairQR(pair)
|
||||||
const qrConfig = {
|
|
||||||
type: "terminal" as const,
|
|
||||||
small: true,
|
|
||||||
errorCorrectionLevel: "M" as const,
|
|
||||||
}
|
|
||||||
log.info("pair qr", {
|
|
||||||
relayURL: pair.relayURL,
|
|
||||||
relaySecretHash: secretHash(pair.relaySecret),
|
|
||||||
serverID: pair.serverID,
|
|
||||||
hosts: pair.hosts,
|
|
||||||
hostCount: pair.hosts.length,
|
|
||||||
hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")),
|
|
||||||
linkLength: link.length,
|
|
||||||
qr: qrConfig,
|
|
||||||
})
|
|
||||||
const code = await QRCode.toString(link, {
|
|
||||||
...qrConfig,
|
|
||||||
})
|
|
||||||
console.log("scan qr code in mobile app or phone camera")
|
|
||||||
console.log(code)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,10 @@ function advertiseURL(input: string, port: number): string | undefined {
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
|
const hasScheme = raw.includes("://")
|
||||||
|
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
|
||||||
if (!parsed.hostname) return
|
if (!parsed.hostname) return
|
||||||
if (!parsed.port) {
|
if (!parsed.port && !hasScheme) {
|
||||||
parsed.port = String(port)
|
parsed.port = String(port)
|
||||||
}
|
}
|
||||||
return norm(`${parsed.protocol}//${parsed.host}`)
|
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||||
|
|
@ -205,6 +206,22 @@ function fallback(input: Type) {
|
||||||
return "OpenCode reported an error for your session."
|
return "OpenCode reported an error for your session."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function titlePrefix(input: Type) {
|
||||||
|
if (input === "permission") return "Action Needed"
|
||||||
|
if (input === "error") return "Error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleForType(input: Type, title: string) {
|
||||||
|
const next = text(title)
|
||||||
|
if (!next) return next
|
||||||
|
const prefix = titlePrefix(input)
|
||||||
|
if (!prefix) return next
|
||||||
|
const tagged = `${prefix}:`
|
||||||
|
if (next.toLowerCase().startsWith(tagged.toLowerCase())) return next
|
||||||
|
return `${tagged} ${next}`
|
||||||
|
}
|
||||||
|
|
||||||
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
|
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
|
||||||
const out: Notify = {
|
const out: Notify = {
|
||||||
type: input.type,
|
type: input.type,
|
||||||
|
|
@ -252,6 +269,7 @@ async function notify(input: { type: Type; sessionID: string }): Promise<Notify>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!out.title) out.title = `Session ${input.type}`
|
if (!out.title) out.title = `Session ${input.type}`
|
||||||
|
out.title = titleForType(input.type, out.title)
|
||||||
if (!out.body) out.body = fallback(input.type)
|
if (!out.body) out.body = fallback(input.type)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue